first
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Sensitive configuration (use config.toml.example as template)
|
||||||
|
config.toml
|
||||||
|
|
||||||
|
# Data files with sensitive information
|
||||||
|
accounts.csv
|
||||||
|
team.json
|
||||||
|
team_tracker.json
|
||||||
|
domain_blacklist.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
nul
|
||||||
|
|
||||||
|
.claude/settings.local.json
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
459
README.md
Normal file
459
README.md
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
# 🚀 OpenAI Team Auto Provisioner
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**OpenAI Team 账号自动批量注册 & 授权入库工具**
|
||||||
|
|
||||||
|
[](https://www.python.org/)
|
||||||
|
[](https://drissionpage.cn/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- 🔄 **全自动化流程** - 从邮箱创建到授权入库一键完成
|
||||||
|
- 📧 **多邮箱系统支持** - 支持 Cloud Mail 自建邮箱和 GPTMail 临时邮箱
|
||||||
|
- 👥 **Team 批量邀请** - 一次性邀请多个账号到 Team
|
||||||
|
- 🌐 **浏览器自动化** - 基于 DrissionPage 的智能注册
|
||||||
|
- 🔐 **多授权服务支持** - 支持 CRS / CPA / S2A 三种授权入库方式
|
||||||
|
- 💾 **断点续传** - 支持中断恢复,避免重复操作
|
||||||
|
- 📊 **状态追踪** - 详细的账号状态记录与追踪
|
||||||
|
- 🌍 **代理轮换** - 支持多代理配置和自动轮换
|
||||||
|
- 🎭 **浏览器指纹** - 随机浏览器指纹防检测
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 前置要求
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- [uv](https://github.com/astral-sh/uv) (推荐) 或 pip
|
||||||
|
- Chrome 浏览器
|
||||||
|
- 邮箱服务 API
|
||||||
|
- 授权服务 API (CRS / CPA / S2A 任选其一)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 uv (推荐)
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# 或使用 pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制配置模板
|
||||||
|
cp config.toml.example config.toml
|
||||||
|
cp team.json.example team.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 编辑配置
|
||||||
|
|
||||||
|
#### `config.toml` - 主配置文件
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# ==================== 服务选择 ====================
|
||||||
|
# 邮箱系统: "cloudmail" (自建邮箱) 或 "gptmail" (临时邮箱)
|
||||||
|
email_provider = "gptmail"
|
||||||
|
|
||||||
|
# 授权服务: "crs" / "cpa" / "s2a"
|
||||||
|
auth_provider = "cpa"
|
||||||
|
|
||||||
|
# 是否将 Team Owner 也添加到授权服务入库
|
||||||
|
# - true: Owner 账号也会进行授权并入库 (需要 Owner 邮箱能接收验证码)
|
||||||
|
# - false: 仅处理邀请的成员账号,Owner 不入库
|
||||||
|
include_team_owners = false
|
||||||
|
|
||||||
|
# ==================== 邮箱服务配置 ====================
|
||||||
|
# Cloud Mail 邮箱服务 (email_provider = "cloudmail" 时使用)
|
||||||
|
[email]
|
||||||
|
api_base = "https://your-email-service.com/api/public"
|
||||||
|
api_auth = "your-api-auth-token"
|
||||||
|
domains = ["domain1.com", "domain2.com"]
|
||||||
|
role = "gpt-team"
|
||||||
|
web_url = "https://your-email-service.com"
|
||||||
|
|
||||||
|
# GPTMail 临时邮箱 (email_provider = "gptmail" 时使用)
|
||||||
|
[gptmail]
|
||||||
|
api_base = "https://mail.chatgpt.org.uk"
|
||||||
|
api_key = "gpt-test"
|
||||||
|
prefix = "" # 邮箱前缀,留空自动生成
|
||||||
|
domains = [] # 可用域名列表,留空使用默认
|
||||||
|
|
||||||
|
# ==================== 授权服务配置 ====================
|
||||||
|
# CRS 服务配置 (auth_provider = "crs" 时使用)
|
||||||
|
[crs]
|
||||||
|
api_base = "https://your-crs-service.com"
|
||||||
|
admin_token = "your-admin-token"
|
||||||
|
|
||||||
|
# CPA 服务 (auth_provider = "cpa" 时使用)
|
||||||
|
[cpa]
|
||||||
|
api_base = "http://your-cpa-service:8317"
|
||||||
|
admin_password = "your-admin-password"
|
||||||
|
poll_interval = 2 # 轮询间隔 (秒)
|
||||||
|
poll_max_retries = 30 # 最大重试次数
|
||||||
|
is_webui = true
|
||||||
|
|
||||||
|
# S2A 服务 (auth_provider = "s2a" 时使用)
|
||||||
|
[s2a]
|
||||||
|
api_base = "https://your-sub2api-service.com/api/v1"
|
||||||
|
admin_key = "your-admin-api-key" # Admin API Key (推荐)
|
||||||
|
admin_token = "" # JWT Token (备选)
|
||||||
|
concurrency = 10 # 账号并发数
|
||||||
|
priority = 50 # 账号优先级
|
||||||
|
group_ids = [] # 分组 ID 列表
|
||||||
|
group_names = ["codex"] # 分组名称列表 (自动解析为 ID)
|
||||||
|
|
||||||
|
# ==================== 账号配置 ====================
|
||||||
|
[account]
|
||||||
|
default_password = "YourSecurePassword@2025"
|
||||||
|
accounts_per_team = 4
|
||||||
|
|
||||||
|
# ==================== 浏览器配置 ====================
|
||||||
|
[browser]
|
||||||
|
wait_timeout = 60
|
||||||
|
short_wait = 10
|
||||||
|
headless = false # 无头模式
|
||||||
|
|
||||||
|
# ==================== 代理配置 (可选) ====================
|
||||||
|
proxy_enabled = false
|
||||||
|
|
||||||
|
# [[proxies]]
|
||||||
|
# type = "socks5"
|
||||||
|
# host = "127.0.0.1"
|
||||||
|
# port = 1080
|
||||||
|
|
||||||
|
# 更多配置项请参考 config.toml.example
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `team.json` - Team 凭证配置
|
||||||
|
|
||||||
|
> 💡 通过访问 `https://chatgpt.com/api/auth/session` 获取(需先登录 ChatGPT)
|
||||||
|
|
||||||
|
**格式 1: 旧格式 (完整 Session)**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"user": { "email": "team-admin@example.com" },
|
||||||
|
"account": {
|
||||||
|
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"organizationId": "org-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
},
|
||||||
|
"accessToken": "eyJhbGciOiJSUzI1NiIs..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **注意**: 格式 1 的 Team Owner 邮箱必须是自己可控的域名邮箱,才能接收验证码完成授权入库。如果是第三方邮箱(如 Gmail),Owner 账号无法入库,只能邀请成员账号。
|
||||||
|
|
||||||
|
**格式 2: 新格式 (邮箱密码 + Token)**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"account": "team-admin@example.com",
|
||||||
|
"password": "your-password",
|
||||||
|
"token": "eyJhbGciOiJSUzI1NiIs..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**格式 3: 新格式 (仅邮箱密码,自动登录获取 Token)**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"account": "team-admin@example.com",
|
||||||
|
"password": "your-password"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 格式 3 无需提供 Token,程序会自动登录获取并保存到配置文件
|
||||||
|
|
||||||
|
#### 三种格式的处理策略
|
||||||
|
|
||||||
|
| 格式 | Owner 授权入库 | 成员邀请 | Token 获取 | 适用场景 |
|
||||||
|
|:---:|:---:|:---:|:---:|------|
|
||||||
|
| **格式 1** | ⚠️ 需自有域名邮箱 | ✅ 支持 | 手动提供 | 已有 Session,Owner 用第三方邮箱 |
|
||||||
|
| **格式 2** | ✅ 支持 | ✅ 支持 | 手动提供 | 已有 Token,Owner 可接收验证码 |
|
||||||
|
| **格式 3** | ✅ 支持 | ✅ 支持 | 自动登录 | 最简配置,Owner 可接收验证码 |
|
||||||
|
|
||||||
|
**详细说明:**
|
||||||
|
|
||||||
|
- **格式 1 (旧格式)**: 直接使用 ChatGPT Session,Owner 使用 OTP 验证码登录授权。如果 Owner 邮箱是 Gmail 等第三方邮箱,无法接收验证码,则 Owner 账号不会入库,仅邀请成员账号。
|
||||||
|
|
||||||
|
- **格式 2 (新格式 + Token)**: 提供邮箱密码和 Token,Owner 使用密码登录进行授权。适合已经有 Token 且 Owner 邮箱可接收验证码的场景。
|
||||||
|
|
||||||
|
- **格式 3 (新格式 无 Token)**: 仅提供邮箱密码,程序会先自动登录获取 Token 并保存,然后进行后续流程。最简单的配置方式,推荐使用。
|
||||||
|
|
||||||
|
### 4. 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有 Team
|
||||||
|
uv run python run.py
|
||||||
|
|
||||||
|
# 单个 Team 模式
|
||||||
|
uv run python run.py single 0
|
||||||
|
|
||||||
|
# 测试模式 (仅创建邮箱和邀请)
|
||||||
|
uv run python run.py test
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
uv run python run.py status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 授权服务说明
|
||||||
|
|
||||||
|
本项目支持三种授权入库服务,通过 `auth_provider` 配置选择:
|
||||||
|
|
||||||
|
| 服务 | 说明 | 特点 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| **CRS** | Claude Relay Service | 需手动添加账号,支持 Token 管理 |
|
||||||
|
| **CPA** | Codex/Copilot Authorization | 后台自动处理,轮询授权状态 |
|
||||||
|
| **S2A** | Sub2API | 支持 OAuth 授权,自动入库,支持分组 |
|
||||||
|
|
||||||
|
### CRS 配置
|
||||||
|
|
||||||
|
```toml
|
||||||
|
auth_provider = "crs"
|
||||||
|
|
||||||
|
[crs]
|
||||||
|
api_base = "https://your-crs-service.com"
|
||||||
|
admin_token = "your-admin-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CPA 配置
|
||||||
|
|
||||||
|
```toml
|
||||||
|
auth_provider = "cpa"
|
||||||
|
|
||||||
|
[cpa]
|
||||||
|
api_base = "http://your-cpa-service:8317"
|
||||||
|
admin_password = "your-admin-password"
|
||||||
|
poll_interval = 2
|
||||||
|
poll_max_retries = 30
|
||||||
|
is_webui = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### S2A 配置
|
||||||
|
|
||||||
|
```toml
|
||||||
|
auth_provider = "s2a"
|
||||||
|
|
||||||
|
[s2a]
|
||||||
|
api_base = "https://your-sub2api-service.com/api/v1"
|
||||||
|
# 认证方式二选一 (优先使用 admin_key)
|
||||||
|
admin_key = "your-admin-api-key"
|
||||||
|
admin_token = ""
|
||||||
|
# 账号配置
|
||||||
|
concurrency = 10
|
||||||
|
priority = 50
|
||||||
|
# 分组配置 (二选一)
|
||||||
|
group_ids = []
|
||||||
|
group_names = ["codex"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
oai-team-auto-provisioner/
|
||||||
|
│
|
||||||
|
├── 🚀 run.py # 主入口脚本
|
||||||
|
├── ⚙️ config.py # 配置加载模块
|
||||||
|
│
|
||||||
|
├── 📧 email_service.py # 邮箱服务 (创建用户、获取验证码)
|
||||||
|
├── 👥 team_service.py # Team 服务 (邀请管理)
|
||||||
|
├── 🌐 browser_automation.py # 浏览器自动化 (注册流程)
|
||||||
|
│
|
||||||
|
├── 🔐 授权服务模块
|
||||||
|
│ ├── crs_service.py # CRS 服务
|
||||||
|
│ ├── cpa_service.py # CPA 服务
|
||||||
|
│ └── s2a_service.py # S2A (Sub2API) 服务
|
||||||
|
│
|
||||||
|
├── 🛠️ utils.py # 工具函数 (CSV、状态追踪)
|
||||||
|
├── 📊 logger.py # 日志模块
|
||||||
|
│
|
||||||
|
├── 📝 config.toml.example # 配置模板
|
||||||
|
├── 🔑 team.json.example # Team 凭证模板
|
||||||
|
│
|
||||||
|
├── 📂 logs/ # 日志目录
|
||||||
|
│ └── app.log # 运行日志 (自动轮转)
|
||||||
|
│
|
||||||
|
└── 📂 自动生成文件
|
||||||
|
├── accounts.csv # 账号记录
|
||||||
|
├── team_tracker.json # 状态追踪
|
||||||
|
└── domain_blacklist.json # 域名黑名单
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 工作流程
|
||||||
|
|
||||||
|
### 整体流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 🚀 python run.py │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌──────────▼───────────┐
|
||||||
|
│ 📋 加载配置文件 │
|
||||||
|
│ config.toml │
|
||||||
|
│ team.json │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌──────────▼───────────┐
|
||||||
|
│ 🔐 验证授权服务连接 │
|
||||||
|
│ CRS / CPA / S2A │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────────┼─────────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ 格式 3 │ │ 格式 2 │ │ 格式 1 │
|
||||||
|
│ 仅邮箱密码 │ │邮箱密码+Token│ │ 完整Session │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
▼ │ │
|
||||||
|
┌─────────────┐ │ │
|
||||||
|
│ 🔑 浏览器 │ │ │
|
||||||
|
│ 自动登录 │ │ │
|
||||||
|
│ 获取Token │ │ │
|
||||||
|
│ 同时授权 │ │ │
|
||||||
|
└──────┬──────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
└───────────────────────────┼───────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 🔁 遍历每个 Team │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────▼───────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 📧 Step 1: 批量创建邮箱 │
|
||||||
|
│ └─ Cloud Mail / GPTMail API │
|
||||||
|
│ │
|
||||||
|
│ 👥 Step 2: 批量邀请到 Team │
|
||||||
|
│ └─ ChatGPT API 邀请成员 │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ 🔁 遍历每个邮箱账号 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 🌐 Step 3: 浏览器自动注册 │ │
|
||||||
|
│ │ └─ DrissionPage 自动化 │ │
|
||||||
|
│ │ └─ 填写信息 + 邮箱验证码 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 🔐 Step 4: Codex 授权 │ │
|
||||||
|
│ │ └─ 生成授权链接 │ │
|
||||||
|
│ │ └─ 自动登录完成授权 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 💾 Step 5: 授权服务入库 │ │
|
||||||
|
│ │ └─ CRS: 手动添加账号 │ │
|
||||||
|
│ │ └─ CPA: 后台自动处理 │ │
|
||||||
|
│ │ └─ S2A: OAuth 自动入库 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└───────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────▼───────────────────┐
|
||||||
|
│ 👑 处理 Team Owner (可选) │
|
||||||
|
│ └─ include_team_owners = true │
|
||||||
|
└───────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────▼───────────┐
|
||||||
|
│ ✅ 完成 & 打印摘要 │
|
||||||
|
│ 📊 保存 CSV 记录 │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态流转
|
||||||
|
|
||||||
|
账号在处理过程中会经历以下状态:
|
||||||
|
|
||||||
|
```
|
||||||
|
invited → registered → authorized → completed
|
||||||
|
│ │ │
|
||||||
|
│ │ └─→ partial (入库失败)
|
||||||
|
│ │
|
||||||
|
│ └─→ auth_failed (授权失败)
|
||||||
|
│
|
||||||
|
└─→ register_failed (注册失败)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 状态 | 说明 | 下一步操作 |
|
||||||
|
|:---:|------|------|
|
||||||
|
| `invited` | 已邀请到 Team | 等待注册 |
|
||||||
|
| `registered` | 已完成注册 | 等待授权 |
|
||||||
|
| `authorized` | 已完成授权 | 等待入库 |
|
||||||
|
| `completed` | 全部完成 | 无 |
|
||||||
|
| `partial` | 授权成功但入库失败 | 重试入库 |
|
||||||
|
| `auth_failed` | 授权失败 | 重试授权 |
|
||||||
|
| `register_failed` | 注册失败 | 重试注册 |
|
||||||
|
| `team_owner` | Team Owner (格式1) | OTP 验证码登录授权 |
|
||||||
|
|
||||||
|
> 💡 程序支持断点续传,中断后重新运行会自动从未完成的状态继续处理
|
||||||
|
|
||||||
|
### 三种格式的处理差异
|
||||||
|
|
||||||
|
| 阶段 | 格式 1 (完整Session) | 格式 2 (邮箱密码+Token) | 格式 3 (仅邮箱密码) |
|
||||||
|
|:---:|:---:|:---:|:---:|
|
||||||
|
| **Token 获取** | 手动提供 accessToken | 手动提供 token | 自动登录获取 |
|
||||||
|
| **Owner 登录方式** | OTP 验证码 | 密码登录 | 密码登录 (登录时同时授权) |
|
||||||
|
| **Owner 授权** | 需自有域名邮箱收验证码 | 密码登录后授权 | 登录时已完成 |
|
||||||
|
| **成员处理** | 正常流程 | 正常流程 | 正常流程 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 输出文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `accounts.csv` | 所有账号记录 (邮箱、密码、Team、状态、授权 ID) |
|
||||||
|
| `team_tracker.json` | 每个 Team 的账号处理状态追踪 |
|
||||||
|
| `domain_blacklist.json` | 不可用的邮箱域名黑名单 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 相关项目
|
||||||
|
|
||||||
|
### 📧 邮箱服务
|
||||||
|
|
||||||
|
| 服务 | 说明 | 配置 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Cloud Mail](https://github.com/maillab/cloud-mail) | 自建邮箱系统 | `email_provider = "cloudmail"` |
|
||||||
|
| [GPTMail](https://mail.chatgpt.org.uk) | 临时邮箱服务 ([API 文档](https://www.chatgpt.org.uk/2025/11/gptmailapiapi.html)) | `email_provider = "gptmail"` |
|
||||||
|
|
||||||
|
### 🔐 授权服务
|
||||||
|
|
||||||
|
| 服务 | 说明 | 配置 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Claude Relay Service](https://github.com/Wei-Shaw/claude-relay-service) | Token 管理服务 | `auth_provider = "crs"` |
|
||||||
|
| [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) | Codex 授权服务 | `auth_provider = "cpa"` |
|
||||||
|
| [Sub2API](https://github.com/Wei-Shaw/sub2api) | OAuth 授权入库 | `auth_provider = "s2a"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 免责声明
|
||||||
|
|
||||||
|
本项目仅供学习和研究使用。使用者需自行承担使用风险,请遵守相关服务条款。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
319
bot_notifier.py
Normal file
319
bot_notifier.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# ==================== Telegram 通知模块 ====================
|
||||||
|
# 用于将日志和状态变更推送到 Telegram
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Callable, List, Optional, Dict
|
||||||
|
from telegram import Bot, Message
|
||||||
|
from telegram.error import TelegramError
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
TELEGRAM_ADMIN_CHAT_IDS,
|
||||||
|
TELEGRAM_NOTIFY_ON_COMPLETE,
|
||||||
|
TELEGRAM_NOTIFY_ON_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_progress_bar(current: int, total: int, width: int = 10) -> str:
|
||||||
|
"""生成文本进度条
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: 当前进度
|
||||||
|
total: 总数
|
||||||
|
width: 进度条宽度 (字符数)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
进度条字符串,如 "████████░░ 80%"
|
||||||
|
"""
|
||||||
|
if total <= 0:
|
||||||
|
return "░" * width + " 0%"
|
||||||
|
|
||||||
|
percent = min(current / total, 1.0)
|
||||||
|
filled = int(width * percent)
|
||||||
|
empty = width - filled
|
||||||
|
|
||||||
|
bar = "█" * filled + "░" * empty
|
||||||
|
percent_text = f"{int(percent * 100)}%"
|
||||||
|
|
||||||
|
return f"{bar} {percent_text}"
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressTracker:
|
||||||
|
"""进度跟踪器 - 用于实时更新 Telegram 消息显示进度"""
|
||||||
|
|
||||||
|
def __init__(self, bot: Bot, chat_ids: List[int], team_name: str, total: int):
|
||||||
|
self.bot = bot
|
||||||
|
self.chat_ids = chat_ids
|
||||||
|
self.team_name = team_name
|
||||||
|
self.total = total
|
||||||
|
self.current = 0
|
||||||
|
self.success = 0
|
||||||
|
self.failed = 0
|
||||||
|
self.current_account = ""
|
||||||
|
self.current_step = ""
|
||||||
|
self.messages: Dict[int, Message] = {} # chat_id -> Message
|
||||||
|
self._last_update = 0
|
||||||
|
self._update_interval = 2 # 最小更新间隔 (秒)
|
||||||
|
self._loop: asyncio.AbstractEventLoop = None
|
||||||
|
|
||||||
|
def _get_progress_text(self) -> str:
|
||||||
|
"""生成进度消息文本"""
|
||||||
|
bar = make_progress_bar(self.current, self.total, 12)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"<b>Processing: {self.team_name}</b>",
|
||||||
|
"",
|
||||||
|
f"Progress: {bar}",
|
||||||
|
f"Accounts: {self.current}/{self.total}",
|
||||||
|
f"Success: {self.success} | Failed: {self.failed}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.current_account:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Current: <code>{self.current_account}</code>")
|
||||||
|
|
||||||
|
if self.current_step:
|
||||||
|
lines.append(f"Step: {self.current_step}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def _send_initial_message(self):
|
||||||
|
"""发送初始进度消息"""
|
||||||
|
text = self._get_progress_text()
|
||||||
|
for chat_id in self.chat_ids:
|
||||||
|
try:
|
||||||
|
msg = await self.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
self.messages[chat_id] = msg
|
||||||
|
except TelegramError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _update_messages(self):
|
||||||
|
"""更新所有进度消息"""
|
||||||
|
text = self._get_progress_text()
|
||||||
|
for chat_id, msg in self.messages.items():
|
||||||
|
try:
|
||||||
|
await self.bot.edit_message_text(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=msg.message_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
except TelegramError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _schedule_update(self):
|
||||||
|
"""调度消息更新 (限流)"""
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_update < self._update_interval:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_update = now
|
||||||
|
|
||||||
|
if self._loop:
|
||||||
|
asyncio.run_coroutine_threadsafe(self._update_messages(), self._loop)
|
||||||
|
|
||||||
|
def start(self, loop: asyncio.AbstractEventLoop):
|
||||||
|
"""启动进度跟踪"""
|
||||||
|
self._loop = loop
|
||||||
|
asyncio.run_coroutine_threadsafe(self._send_initial_message(), loop)
|
||||||
|
|
||||||
|
def update(self, current: int = None, account: str = None, step: str = None):
|
||||||
|
"""更新进度 (供同步代码调用)"""
|
||||||
|
if current is not None:
|
||||||
|
self.current = current
|
||||||
|
if account is not None:
|
||||||
|
self.current_account = account
|
||||||
|
if step is not None:
|
||||||
|
self.current_step = step
|
||||||
|
|
||||||
|
self._schedule_update()
|
||||||
|
|
||||||
|
def account_done(self, email: str, success: bool):
|
||||||
|
"""标记账号处理完成"""
|
||||||
|
self.current += 1
|
||||||
|
if success:
|
||||||
|
self.success += 1
|
||||||
|
else:
|
||||||
|
self.failed += 1
|
||||||
|
self.current_account = ""
|
||||||
|
self.current_step = ""
|
||||||
|
self._schedule_update()
|
||||||
|
|
||||||
|
def finish(self):
|
||||||
|
"""完成进度跟踪,发送最终状态"""
|
||||||
|
self.current_step = "Completed!"
|
||||||
|
if self._loop:
|
||||||
|
asyncio.run_coroutine_threadsafe(self._update_messages(), self._loop)
|
||||||
|
|
||||||
|
|
||||||
|
class BotNotifier:
|
||||||
|
"""Telegram 通知推送器"""
|
||||||
|
|
||||||
|
def __init__(self, bot: Bot, chat_ids: List[int] = None):
|
||||||
|
self.bot = bot
|
||||||
|
self.chat_ids = chat_ids or TELEGRAM_ADMIN_CHAT_IDS
|
||||||
|
self._message_queue: asyncio.Queue = None
|
||||||
|
self._worker_task: asyncio.Task = None
|
||||||
|
self._current_progress: Optional[ProgressTracker] = None
|
||||||
|
self._loop: asyncio.AbstractEventLoop = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""启动消息发送队列"""
|
||||||
|
self._message_queue = asyncio.Queue()
|
||||||
|
self._worker_task = asyncio.create_task(self._message_worker())
|
||||||
|
self._loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""停止消息发送队列"""
|
||||||
|
if self._worker_task:
|
||||||
|
self._worker_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._worker_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _message_worker(self):
|
||||||
|
"""后台消息发送工作线程"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message, level = await self._message_queue.get()
|
||||||
|
await self._send_to_all(message)
|
||||||
|
self._message_queue.task_done()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _send_to_all(self, message: str, parse_mode: str = "HTML"):
|
||||||
|
"""发送消息到所有管理员"""
|
||||||
|
for chat_id in self.chat_ids:
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=message,
|
||||||
|
parse_mode=parse_mode
|
||||||
|
)
|
||||||
|
except TelegramError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def queue_message(self, message: str, level: str = "info"):
|
||||||
|
"""将消息加入发送队列 (非阻塞)"""
|
||||||
|
if self._message_queue:
|
||||||
|
try:
|
||||||
|
self._message_queue.put_nowait((message, level))
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def notify(self, message: str, level: str = "info"):
|
||||||
|
"""直接发送通知 (阻塞)"""
|
||||||
|
await self._send_to_all(message)
|
||||||
|
|
||||||
|
def create_progress(self, team_name: str, total: int) -> ProgressTracker:
|
||||||
|
"""创建进度跟踪器"""
|
||||||
|
self._current_progress = ProgressTracker(
|
||||||
|
self.bot, self.chat_ids, team_name, total
|
||||||
|
)
|
||||||
|
if self._loop:
|
||||||
|
self._current_progress.start(self._loop)
|
||||||
|
return self._current_progress
|
||||||
|
|
||||||
|
def get_progress(self) -> Optional[ProgressTracker]:
|
||||||
|
"""获取当前进度跟踪器"""
|
||||||
|
return self._current_progress
|
||||||
|
|
||||||
|
def clear_progress(self):
|
||||||
|
"""清除进度跟踪器"""
|
||||||
|
if self._current_progress:
|
||||||
|
self._current_progress.finish()
|
||||||
|
self._current_progress = None
|
||||||
|
|
||||||
|
async def notify_task_started(self, team_name: str):
|
||||||
|
"""通知任务开始"""
|
||||||
|
await self.notify(f"<b>Task Started</b>\nTeam: {team_name}")
|
||||||
|
|
||||||
|
async def notify_task_completed(self, team_name: str, success: int, failed: int):
|
||||||
|
"""通知任务完成"""
|
||||||
|
if not TELEGRAM_NOTIFY_ON_COMPLETE:
|
||||||
|
return
|
||||||
|
status = "All Success" if failed == 0 else f"{failed} Failed"
|
||||||
|
await self.notify(
|
||||||
|
f"<b>Task Completed</b>\n"
|
||||||
|
f"Team: {team_name}\n"
|
||||||
|
f"Success: {success}\n"
|
||||||
|
f"Status: {status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_error(self, message: str, details: str = ""):
|
||||||
|
"""通知错误"""
|
||||||
|
if not TELEGRAM_NOTIFY_ON_ERROR:
|
||||||
|
return
|
||||||
|
text = f"<b>Error</b>\n{message}"
|
||||||
|
if details:
|
||||||
|
text += f"\n<code>{details[:500]}</code>"
|
||||||
|
await self.notify(text)
|
||||||
|
|
||||||
|
async def notify_account_status(self, email: str, status: str, team_name: str = ""):
|
||||||
|
"""通知账号状态变更"""
|
||||||
|
icon = {
|
||||||
|
"completed": "OK",
|
||||||
|
"authorized": "AUTH",
|
||||||
|
"registered": "REG",
|
||||||
|
"failed": "FAIL",
|
||||||
|
}.get(status, status.upper())
|
||||||
|
|
||||||
|
text = f"[{icon}] {email}"
|
||||||
|
if team_name:
|
||||||
|
text = f"<b>{team_name}</b>\n{text}"
|
||||||
|
self.queue_message(text, "info")
|
||||||
|
|
||||||
|
|
||||||
|
# 全局通知器实例 (在 telegram_bot.py 中初始化)
|
||||||
|
_notifier: Optional[BotNotifier] = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_notifier(notifier: BotNotifier):
|
||||||
|
"""设置全局通知器"""
|
||||||
|
global _notifier
|
||||||
|
_notifier = notifier
|
||||||
|
|
||||||
|
|
||||||
|
def get_notifier() -> Optional[BotNotifier]:
|
||||||
|
"""获取全局通知器"""
|
||||||
|
return _notifier
|
||||||
|
|
||||||
|
|
||||||
|
def notify_sync(message: str, level: str = "info"):
|
||||||
|
"""同步方式发送通知 (供非异步代码使用)"""
|
||||||
|
if _notifier:
|
||||||
|
_notifier.queue_message(message, level)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 进度更新接口 (供 run.py 使用) ====================
|
||||||
|
|
||||||
|
def progress_start(team_name: str, total: int) -> Optional[ProgressTracker]:
|
||||||
|
"""开始进度跟踪"""
|
||||||
|
if _notifier:
|
||||||
|
return _notifier.create_progress(team_name, total)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def progress_update(account: str = None, step: str = None):
|
||||||
|
"""更新当前进度"""
|
||||||
|
if _notifier and _notifier.get_progress():
|
||||||
|
_notifier.get_progress().update(account=account, step=step)
|
||||||
|
|
||||||
|
|
||||||
|
def progress_account_done(email: str, success: bool):
|
||||||
|
"""标记账号完成"""
|
||||||
|
if _notifier and _notifier.get_progress():
|
||||||
|
_notifier.get_progress().account_done(email, success)
|
||||||
|
|
||||||
|
|
||||||
|
def progress_finish():
|
||||||
|
"""完成进度跟踪"""
|
||||||
|
if _notifier:
|
||||||
|
_notifier.clear_progress()
|
||||||
2511
browser_automation.py
Normal file
2511
browser_automation.py
Normal file
File diff suppressed because it is too large
Load Diff
510
config.py
Normal file
510
config.py
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# ==================== 配置模块 ====================
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import tomli as tomllib
|
||||||
|
except ImportError:
|
||||||
|
tomllib = None
|
||||||
|
|
||||||
|
# ==================== 路径 ====================
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
CONFIG_FILE = BASE_DIR / "config.toml"
|
||||||
|
TEAM_JSON_FILE = BASE_DIR / "team.json"
|
||||||
|
|
||||||
|
# ==================== 配置加载日志 ====================
|
||||||
|
# 由于 config.py 在 logger.py 之前加载,使用简单的打印函数记录错误
|
||||||
|
# 这些错误会在程序启动时显示
|
||||||
|
|
||||||
|
_config_errors = [] # 存储配置加载错误,供后续日志记录
|
||||||
|
|
||||||
|
|
||||||
|
def _log_config(level: str, source: str, message: str, details: str = None):
|
||||||
|
"""记录配置加载日志 (启动时使用)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: 日志级别 (INFO/WARNING/ERROR)
|
||||||
|
source: 配置来源
|
||||||
|
message: 消息
|
||||||
|
details: 详细信息
|
||||||
|
"""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
full_msg = f"[{timestamp}] [{level}] 配置 [{source}]: {message}"
|
||||||
|
if details:
|
||||||
|
full_msg += f" - {details}"
|
||||||
|
|
||||||
|
# 打印到控制台
|
||||||
|
if level == "ERROR":
|
||||||
|
print(f"\033[91m{full_msg}\033[0m", file=sys.stderr)
|
||||||
|
elif level == "WARNING":
|
||||||
|
print(f"\033[93m{full_msg}\033[0m", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(full_msg)
|
||||||
|
|
||||||
|
# 存储错误信息供后续使用
|
||||||
|
if level in ("ERROR", "WARNING"):
|
||||||
|
_config_errors.append({"level": level, "source": source, "message": message, "details": details})
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_errors() -> list:
|
||||||
|
"""获取配置加载时的错误列表"""
|
||||||
|
return _config_errors.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_toml() -> dict:
|
||||||
|
"""加载 TOML 配置文件"""
|
||||||
|
if tomllib is None:
|
||||||
|
_log_config("WARNING", "config.toml", "tomllib 未安装", "请安装 tomli: pip install tomli")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
_log_config("WARNING", "config.toml", "配置文件不存在", str(CONFIG_FILE))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "rb") as f:
|
||||||
|
config = tomllib.load(f)
|
||||||
|
_log_config("INFO", "config.toml", "配置文件加载成功")
|
||||||
|
return config
|
||||||
|
except tomllib.TOMLDecodeError as e:
|
||||||
|
_log_config("ERROR", "config.toml", "TOML 解析错误", str(e))
|
||||||
|
return {}
|
||||||
|
except PermissionError:
|
||||||
|
_log_config("ERROR", "config.toml", "权限不足,无法读取配置文件")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
_log_config("ERROR", "config.toml", "加载失败", f"{type(e).__name__}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_teams() -> list:
|
||||||
|
"""加载 Team 配置文件"""
|
||||||
|
if not TEAM_JSON_FILE.exists():
|
||||||
|
_log_config("WARNING", "team.json", "Team 配置文件不存在", str(TEAM_JSON_FILE))
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(TEAM_JSON_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
teams = data if isinstance(data, list) else [data]
|
||||||
|
_log_config("INFO", "team.json", f"加载了 {len(teams)} 个 Team 配置")
|
||||||
|
return teams
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
_log_config("ERROR", "team.json", "JSON 解析错误", str(e))
|
||||||
|
return []
|
||||||
|
except PermissionError:
|
||||||
|
_log_config("ERROR", "team.json", "权限不足,无法读取配置文件")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
_log_config("ERROR", "team.json", "加载失败", f"{type(e).__name__}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 加载配置 ====================
|
||||||
|
_cfg = _load_toml()
|
||||||
|
_raw_teams = _load_teams()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_team_config(t: dict, index: int) -> dict:
|
||||||
|
"""解析单个 Team 配置,支持多种格式
|
||||||
|
|
||||||
|
格式1 (旧格式):
|
||||||
|
{
|
||||||
|
"user": {"email": "xxx@xxx.com"},
|
||||||
|
"account": {"id": "...", "organizationId": "..."},
|
||||||
|
"accessToken": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
格式2/3 (新格式):
|
||||||
|
{
|
||||||
|
"account": "xxx@xxx.com", # 邮箱
|
||||||
|
"password": "...", # 密码
|
||||||
|
"token": "...", # accessToken (格式3无此字段)
|
||||||
|
"authorized": true # 是否已授权 (格式3授权后添加)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 检测格式类型
|
||||||
|
if isinstance(t.get("account"), str):
|
||||||
|
# 新格式: account 是邮箱字符串
|
||||||
|
email = t.get("account", "")
|
||||||
|
name = email.split("@")[0] if "@" in email else f"Team{index+1}"
|
||||||
|
token = t.get("token", "")
|
||||||
|
authorized = t.get("authorized", False)
|
||||||
|
cached_account_id = t.get("account_id", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"account_id": cached_account_id,
|
||||||
|
"org_id": "",
|
||||||
|
"auth_token": token,
|
||||||
|
"owner_email": email,
|
||||||
|
"owner_password": t.get("password", ""),
|
||||||
|
"needs_login": not token, # 无 token 需要登录
|
||||||
|
"authorized": authorized, # 是否已授权
|
||||||
|
"format": "new",
|
||||||
|
"raw": t
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 旧格式: account 是对象
|
||||||
|
email = t.get("user", {}).get("email", f"Team{index+1}")
|
||||||
|
name = email.split("@")[0] if "@" in email else f"Team{index+1}"
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"account_id": t.get("account", {}).get("id", ""),
|
||||||
|
"org_id": t.get("account", {}).get("organizationId", ""),
|
||||||
|
"auth_token": t.get("accessToken", ""),
|
||||||
|
"owner_email": email,
|
||||||
|
"owner_password": "",
|
||||||
|
"format": "old",
|
||||||
|
"raw": t
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 转换 team.json 格式为 team_service.py 期望的格式
|
||||||
|
TEAMS = []
|
||||||
|
for i, t in enumerate(_raw_teams):
|
||||||
|
team_config = _parse_team_config(t, i)
|
||||||
|
TEAMS.append(team_config)
|
||||||
|
|
||||||
|
|
||||||
|
def save_team_json():
|
||||||
|
"""保存 team.json (用于持久化 account_id、token、authorized 等动态获取的数据)
|
||||||
|
|
||||||
|
仅对新格式的 Team 配置生效
|
||||||
|
"""
|
||||||
|
if not TEAM_JSON_FILE.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
for team in TEAMS:
|
||||||
|
if team.get("format") == "new":
|
||||||
|
raw = team.get("raw", {})
|
||||||
|
# 保存 account_id
|
||||||
|
if team.get("account_id") and raw.get("account_id") != team["account_id"]:
|
||||||
|
raw["account_id"] = team["account_id"]
|
||||||
|
updated = True
|
||||||
|
# 保存 token
|
||||||
|
if team.get("auth_token") and raw.get("token") != team["auth_token"]:
|
||||||
|
raw["token"] = team["auth_token"]
|
||||||
|
updated = True
|
||||||
|
# 保存 authorized 状态
|
||||||
|
if team.get("authorized") and not raw.get("authorized"):
|
||||||
|
raw["authorized"] = True
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if not updated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(TEAM_JSON_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(_raw_teams, f, ensure_ascii=False, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log_config("ERROR", "team.json", "保存失败", str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 邮箱系统选择
|
||||||
|
EMAIL_PROVIDER = _cfg.get("email_provider", "kyx") # "kyx" 或 "gptmail"
|
||||||
|
|
||||||
|
# 原有邮箱系统 (KYX)
|
||||||
|
_email = _cfg.get("email", {})
|
||||||
|
EMAIL_API_BASE = _email.get("api_base", "")
|
||||||
|
EMAIL_API_AUTH = _email.get("api_auth", "")
|
||||||
|
EMAIL_DOMAINS = _email.get("domains", []) or ([_email["domain"]] if _email.get("domain") else [])
|
||||||
|
EMAIL_DOMAIN = EMAIL_DOMAINS[0] if EMAIL_DOMAINS else ""
|
||||||
|
EMAIL_ROLE = _email.get("role", "gpt-team")
|
||||||
|
EMAIL_WEB_URL = _email.get("web_url", "")
|
||||||
|
|
||||||
|
# GPTMail 临时邮箱配置
|
||||||
|
_gptmail = _cfg.get("gptmail", {})
|
||||||
|
GPTMAIL_API_BASE = _gptmail.get("api_base", "https://mail.chatgpt.org.uk")
|
||||||
|
GPTMAIL_API_KEY = _gptmail.get("api_key", "gpt-test")
|
||||||
|
GPTMAIL_PREFIX = _gptmail.get("prefix", "")
|
||||||
|
GPTMAIL_DOMAINS = _gptmail.get("domains", [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_gptmail_domain() -> str:
|
||||||
|
"""随机获取一个 GPTMail 可用域名 (排除黑名单)"""
|
||||||
|
available = [d for d in GPTMAIL_DOMAINS if d not in _domain_blacklist]
|
||||||
|
if available:
|
||||||
|
return random.choice(available)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 域名黑名单管理 ====================
|
||||||
|
BLACKLIST_FILE = BASE_DIR / "domain_blacklist.json"
|
||||||
|
_domain_blacklist = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_blacklist() -> set:
|
||||||
|
"""加载域名黑名单"""
|
||||||
|
if not BLACKLIST_FILE.exists():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
with open(BLACKLIST_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return set(data.get("domains", []))
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_blacklist():
|
||||||
|
"""保存域名黑名单"""
|
||||||
|
try:
|
||||||
|
with open(BLACKLIST_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"domains": list(_domain_blacklist)}, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def add_domain_to_blacklist(domain: str):
|
||||||
|
"""将域名加入黑名单"""
|
||||||
|
global _domain_blacklist
|
||||||
|
if domain and domain not in _domain_blacklist:
|
||||||
|
_domain_blacklist.add(domain)
|
||||||
|
_save_blacklist()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_domain_blacklisted(domain: str) -> bool:
|
||||||
|
"""检查域名是否在黑名单中"""
|
||||||
|
return domain in _domain_blacklist
|
||||||
|
|
||||||
|
|
||||||
|
def get_domain_from_email(email: str) -> str:
|
||||||
|
"""从邮箱地址提取域名"""
|
||||||
|
if "@" in email:
|
||||||
|
return email.split("@")[1]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_email_blacklisted(email: str) -> bool:
|
||||||
|
"""检查邮箱域名是否在黑名单中"""
|
||||||
|
domain = get_domain_from_email(email)
|
||||||
|
return is_domain_blacklisted(domain)
|
||||||
|
|
||||||
|
|
||||||
|
# 启动时加载黑名单
|
||||||
|
_domain_blacklist = _load_blacklist()
|
||||||
|
|
||||||
|
# 授权服务选择: "crs" 或 "cpa"
|
||||||
|
# 注意: auth_provider 可能在顶层或被误放在 gptmail section 下
|
||||||
|
AUTH_PROVIDER = _cfg.get("auth_provider") or _cfg.get("gptmail", {}).get("auth_provider", "crs")
|
||||||
|
|
||||||
|
# 是否将 Team Owner 也添加到授权服务
|
||||||
|
INCLUDE_TEAM_OWNERS = _cfg.get("include_team_owners", False)
|
||||||
|
|
||||||
|
# CRS
|
||||||
|
_crs = _cfg.get("crs", {})
|
||||||
|
CRS_API_BASE = _crs.get("api_base", "")
|
||||||
|
CRS_ADMIN_TOKEN = _crs.get("admin_token", "")
|
||||||
|
|
||||||
|
# CPA
|
||||||
|
_cpa = _cfg.get("cpa", {})
|
||||||
|
CPA_API_BASE = _cpa.get("api_base", "")
|
||||||
|
CPA_ADMIN_PASSWORD = _cpa.get("admin_password", "")
|
||||||
|
CPA_POLL_INTERVAL = _cpa.get("poll_interval", 2)
|
||||||
|
CPA_POLL_MAX_RETRIES = _cpa.get("poll_max_retries", 30)
|
||||||
|
CPA_IS_WEBUI = _cpa.get("is_webui", True)
|
||||||
|
|
||||||
|
# S2A (Sub2API)
|
||||||
|
_s2a = _cfg.get("s2a", {})
|
||||||
|
S2A_API_BASE = _s2a.get("api_base", "")
|
||||||
|
S2A_ADMIN_KEY = _s2a.get("admin_key", "")
|
||||||
|
S2A_ADMIN_TOKEN = _s2a.get("admin_token", "")
|
||||||
|
S2A_CONCURRENCY = _s2a.get("concurrency", 10)
|
||||||
|
S2A_PRIORITY = _s2a.get("priority", 50)
|
||||||
|
S2A_GROUP_NAMES = _s2a.get("group_names", [])
|
||||||
|
S2A_GROUP_IDS = _s2a.get("group_ids", [])
|
||||||
|
|
||||||
|
# 账号
|
||||||
|
_account = _cfg.get("account", {})
|
||||||
|
DEFAULT_PASSWORD = _account.get("default_password", "kfcvivo50")
|
||||||
|
ACCOUNTS_PER_TEAM = _account.get("accounts_per_team", 4)
|
||||||
|
|
||||||
|
# 注册
|
||||||
|
_reg = _cfg.get("register", {})
|
||||||
|
REGISTER_NAME = _reg.get("name", "test")
|
||||||
|
REGISTER_BIRTHDAY = _reg.get("birthday", {"year": "2000", "month": "01", "day": "01"})
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_birthday() -> dict:
|
||||||
|
"""生成随机生日 (2000-2005年)"""
|
||||||
|
year = str(random.randint(2000, 2005))
|
||||||
|
month = str(random.randint(1, 12)).zfill(2)
|
||||||
|
day = str(random.randint(1, 28)).zfill(2) # 用28避免月份天数问题
|
||||||
|
return {"year": year, "month": month, "day": day}
|
||||||
|
|
||||||
|
# 请求
|
||||||
|
_req = _cfg.get("request", {})
|
||||||
|
REQUEST_TIMEOUT = _req.get("timeout", 30)
|
||||||
|
USER_AGENT = _req.get("user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0")
|
||||||
|
|
||||||
|
# 验证码
|
||||||
|
_ver = _cfg.get("verification", {})
|
||||||
|
VERIFICATION_CODE_TIMEOUT = _ver.get("timeout", 60)
|
||||||
|
VERIFICATION_CODE_INTERVAL = _ver.get("interval", 3)
|
||||||
|
VERIFICATION_CODE_MAX_RETRIES = _ver.get("max_retries", 20)
|
||||||
|
|
||||||
|
# 浏览器
|
||||||
|
_browser = _cfg.get("browser", {})
|
||||||
|
BROWSER_WAIT_TIMEOUT = _browser.get("wait_timeout", 60)
|
||||||
|
BROWSER_SHORT_WAIT = _browser.get("short_wait", 10)
|
||||||
|
BROWSER_HEADLESS = _browser.get("headless", False)
|
||||||
|
|
||||||
|
# 文件
|
||||||
|
_files = _cfg.get("files", {})
|
||||||
|
CSV_FILE = _files.get("csv_file", str(BASE_DIR / "accounts.csv"))
|
||||||
|
TEAM_TRACKER_FILE = _files.get("tracker_file", str(BASE_DIR / "team_tracker.json"))
|
||||||
|
|
||||||
|
# Telegram Bot 配置
|
||||||
|
_telegram = _cfg.get("telegram", {})
|
||||||
|
TELEGRAM_ENABLED = _telegram.get("enabled", False)
|
||||||
|
TELEGRAM_BOT_TOKEN = _telegram.get("bot_token", "")
|
||||||
|
TELEGRAM_ADMIN_CHAT_IDS = _telegram.get("admin_chat_ids", [])
|
||||||
|
TELEGRAM_NOTIFY_ON_COMPLETE = _telegram.get("notify_on_complete", True)
|
||||||
|
TELEGRAM_NOTIFY_ON_ERROR = _telegram.get("notify_on_error", True)
|
||||||
|
TELEGRAM_CHECK_INTERVAL = _telegram.get("check_interval", 3600) # 默认1小时检查一次
|
||||||
|
TELEGRAM_LOW_STOCK_THRESHOLD = _telegram.get("low_stock_threshold", 10) # 低库存阈值
|
||||||
|
|
||||||
|
# 代理
|
||||||
|
PROXY_ENABLED = _cfg.get("proxy_enabled", False)
|
||||||
|
PROXIES = _cfg.get("proxies", []) if PROXY_ENABLED else []
|
||||||
|
_proxy_index = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 代理辅助函数 ====================
|
||||||
|
def get_next_proxy() -> dict:
|
||||||
|
"""轮换获取下一个代理"""
|
||||||
|
global _proxy_index
|
||||||
|
if not PROXIES:
|
||||||
|
return None
|
||||||
|
proxy = PROXIES[_proxy_index % len(PROXIES)]
|
||||||
|
_proxy_index += 1
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_proxy() -> dict:
|
||||||
|
"""随机获取一个代理"""
|
||||||
|
if not PROXIES:
|
||||||
|
return None
|
||||||
|
return random.choice(PROXIES)
|
||||||
|
|
||||||
|
|
||||||
|
def format_proxy_url(proxy: dict) -> str:
|
||||||
|
"""格式化代理URL: socks5://user:pass@host:port"""
|
||||||
|
if not proxy:
|
||||||
|
return None
|
||||||
|
p_type = proxy.get("type", "socks5")
|
||||||
|
host = proxy.get("host", "")
|
||||||
|
port = proxy.get("port", "")
|
||||||
|
user = proxy.get("username", "")
|
||||||
|
pwd = proxy.get("password", "")
|
||||||
|
if user and pwd:
|
||||||
|
return f"{p_type}://{user}:{pwd}@{host}:{port}"
|
||||||
|
return f"{p_type}://{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 随机姓名列表 ====================
|
||||||
|
FIRST_NAMES = [
|
||||||
|
"James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph",
|
||||||
|
"Thomas", "Christopher", "Charles", "Daniel", "Matthew", "Anthony", "Mark",
|
||||||
|
"Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara", "Susan",
|
||||||
|
"Jessica", "Sarah", "Karen", "Emma", "Olivia", "Sophia", "Isabella", "Mia"
|
||||||
|
]
|
||||||
|
|
||||||
|
LAST_NAMES = [
|
||||||
|
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
|
||||||
|
"Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson",
|
||||||
|
"Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Thompson", "White",
|
||||||
|
"Harris", "Clark", "Lewis", "Robinson", "Walker", "Young", "Allen"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_name() -> str:
|
||||||
|
"""获取随机外国名字"""
|
||||||
|
first = random.choice(FIRST_NAMES)
|
||||||
|
last = random.choice(LAST_NAMES)
|
||||||
|
return f"{first} {last}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 浏览器指纹 ====================
|
||||||
|
FINGERPRINTS = [
|
||||||
|
{
|
||||||
|
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"platform": "Win32",
|
||||||
|
"webgl_vendor": "Google Inc. (NVIDIA)",
|
||||||
|
"webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)",
|
||||||
|
"language": "en-US",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"screen": {"width": 1920, "height": 1080}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||||
|
"platform": "Win32",
|
||||||
|
"webgl_vendor": "Google Inc. (AMD)",
|
||||||
|
"webgl_renderer": "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
|
||||||
|
"language": "en-US",
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"screen": {"width": 2560, "height": 1440}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"platform": "MacIntel",
|
||||||
|
"webgl_vendor": "Google Inc. (Apple)",
|
||||||
|
"webgl_renderer": "ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)",
|
||||||
|
"language": "en-US",
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"screen": {"width": 1728, "height": 1117}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||||
|
"platform": "Win32",
|
||||||
|
"webgl_vendor": "Google Inc. (Intel)",
|
||||||
|
"webgl_renderer": "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0)",
|
||||||
|
"language": "en-GB",
|
||||||
|
"timezone": "Europe/London",
|
||||||
|
"screen": {"width": 1920, "height": 1200}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_fingerprint() -> dict:
|
||||||
|
"""随机获取一个浏览器指纹"""
|
||||||
|
return random.choice(FINGERPRINTS)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 邮箱辅助函数 ====================
|
||||||
|
def get_random_domain() -> str:
|
||||||
|
return random.choice(EMAIL_DOMAINS) if EMAIL_DOMAINS else EMAIL_DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
def generate_random_email(prefix_len: int = 8) -> str:
|
||||||
|
prefix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=prefix_len))
|
||||||
|
return f"{prefix}oaiteam@{get_random_domain()}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_email_for_user(username: str) -> str:
|
||||||
|
safe = re.sub(r'[^a-zA-Z0-9]', '', username.lower())[:20]
|
||||||
|
return f"{safe}oaiteam@{get_random_domain()}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_team(index: int = 0) -> dict:
|
||||||
|
return TEAMS[index] if 0 <= index < len(TEAMS) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_by_email(email: str) -> dict:
|
||||||
|
return next((t for t in TEAMS if t.get("user", {}).get("email") == email), {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_by_org(org_id: str) -> dict:
|
||||||
|
return next((t for t in TEAMS if t.get("account", {}).get("organizationId") == org_id), {})
|
||||||
221
config.toml.example
Normal file
221
config.toml.example
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# ==================== OaiTeamCrsRegister 配置文件 ====================
|
||||||
|
# 复制此文件为 config.toml 并填入你的配置
|
||||||
|
# 本配置文件用于管理 OpenAI Team 账号批量注册系统的各项参数
|
||||||
|
|
||||||
|
# ==================== 邮箱服务配置 ====================
|
||||||
|
# 邮箱系统选择:
|
||||||
|
# - "cloudmail": Cloud Mail 自建邮箱系统,需要先创建用户才能收信
|
||||||
|
# - "gptmail": GPTMail 临时邮箱系统,无需创建用户,直接生成即可收信
|
||||||
|
email_provider = "gptmail"
|
||||||
|
|
||||||
|
# ---------- Cloud Mail 邮箱系统配置 ----------
|
||||||
|
# 仅当 email_provider = "cloudmail" 时生效
|
||||||
|
# 项目地址: https://github.com/maillab/cloud-mail
|
||||||
|
# API 文档: https://doc.skymail.ink/api/api-doc.html
|
||||||
|
[email]
|
||||||
|
# API 接口地址
|
||||||
|
api_base = "https://your-email-service.com/api/public"
|
||||||
|
# API 鉴权令牌
|
||||||
|
api_auth = "your-api-auth-token"
|
||||||
|
# 可用邮箱域名列表,随机选择
|
||||||
|
domains = ["example.com", "example.org"]
|
||||||
|
# 邮箱用户角色名称
|
||||||
|
role = ""
|
||||||
|
# 邮箱 Web 管理界面地址
|
||||||
|
web_url = "https://your-email-service.com"
|
||||||
|
|
||||||
|
# ---------- GPTMail 临时邮箱配置 ----------
|
||||||
|
# 仅当 email_provider = "gptmail" 时生效
|
||||||
|
# API 文档: https://www.chatgpt.org.uk/2025/11/gptmailapiapi.html
|
||||||
|
[gptmail]
|
||||||
|
# API 接口地址
|
||||||
|
api_base = "https://mail.chatgpt.org.uk"
|
||||||
|
# API 密钥 (gpt-test 为测试密钥,每日有调用限制)
|
||||||
|
api_key = "gpt-test"
|
||||||
|
# 邮箱前缀 (留空则自动生成 {8位随机字符}-oaiteam 格式)
|
||||||
|
prefix = ""
|
||||||
|
# 可用域名列表,生成邮箱时随机选择
|
||||||
|
# 这些域名已配置 MX 记录指向 GPTMail 服务器
|
||||||
|
# 下面域名是可用的
|
||||||
|
domains = [
|
||||||
|
"29thnewport.org.uk", "2ndwhartonscoutgroup.org.uk",
|
||||||
|
"abrahampath.org.uk", "aiccministry.com",
|
||||||
|
"amyfalconer.co.uk", "aylshamrotary.club",
|
||||||
|
"birdsedgevillagehall.co.uk", "bodyofchristministries.co.uk", "bp-hall.co.uk",
|
||||||
|
"brendansbridge.org.uk", "caye.org.uk", "cccnoahsark.com",
|
||||||
|
"christchurchsouthend.org.uk",
|
||||||
|
"cockertonmethodist.org.uk",
|
||||||
|
"dormerhouseschool.co.uk", "e-quiparts.org.uk",
|
||||||
|
"educationossett.co.uk", "egremonttrust.org.uk",
|
||||||
|
"f4jobseekers.org.uk", "flushingvillageclub.org.uk",
|
||||||
|
"fordslane.org.uk", "friendsofkms.org.uk", "gadshillplace.com",
|
||||||
|
"goleudy.org.uk", "gospelassembly.org.uk", "gospelgeneration.org.uk",
|
||||||
|
"gracesanctuary-rccg.co.uk", "greyhoundwalks.org.uk",
|
||||||
|
"haslemerecfr.org.uk", "hottchurch.org.uk",
|
||||||
|
"hvcrc.org", "ingrambreamishvalley.co.uk", "iqraacademy.org.uk",
|
||||||
|
"kempsonplayers.org", "lbatrust.co.uk", "leicscoopband.co.uk",
|
||||||
|
"lflct.org.uk", "living-water.org.uk", "lovecambodia.co.uk", "lutonsymphony.com",
|
||||||
|
"macclesfieldmvc.org.uk",
|
||||||
|
"mtdalmshouse.fitness", "musicatleamingtonhastings.co.uk", "neuaddowen.org.uk",
|
||||||
|
"newlifedorking.org.uk", "newlifefellowshipuk.com",
|
||||||
|
"ngbotima.com", "northboveymeadow.business", "ocgm.org.uk",
|
||||||
|
"oughtibridgechapel.org.uk",
|
||||||
|
"pontfest.org.uk",
|
||||||
|
"powysbarnowls.com", "ppedu.pp.ua", "rawdhah.academy",
|
||||||
|
"resthavencare.org.uk", "rhalmshouse.church", "rhydwilym.com", "riyo.org.uk",
|
||||||
|
"rmtcweb.co.uk", "sanity-uk.org", "sawley-scouts.org.uk",
|
||||||
|
"sidneymichaelpoland.travel", "skmet.co.uk", "steptogetherdance.org.uk",
|
||||||
|
"stmichaelsflixton.co.uk", "svmc.org.uk", "tasmforvictory.com",
|
||||||
|
"tatendatrust.org.uk", "thestuartfeakinstrust.com", "thewonderbus.org",
|
||||||
|
"thurleighchurchestate.church", "tlcappealeastkent.co.uk",
|
||||||
|
"trees-surrey.org.uk", "vision15.co.uk", "vpachurch.org", "westraintonjubileehall.org.uk",
|
||||||
|
"weymouthdramaclub.co.uk", "wohbc.org.uk",
|
||||||
|
"wsmptfa.org.uk", "wyldegreenurc.org.uk", "xxmailedu.dpdns.org", "yetga.co.uk",
|
||||||
|
"zawauk.org", "zumuntahassociationuk.org"
|
||||||
|
]
|
||||||
|
|
||||||
|
# ==================== 授权服务选择 ====================
|
||||||
|
# 选择使用的授权服务: "crs" / "cpa" / "s2a"
|
||||||
|
# - crs: 原有 CRS 系统,需手动添加账号到 CRS
|
||||||
|
# - cpa: CPA (Codex/Copilot Authorization) 系统,后台自动处理账号
|
||||||
|
# - s2a: Sub2API 系统,支持 OAuth 授权和账号入库
|
||||||
|
auth_provider = "cpa"
|
||||||
|
|
||||||
|
# 是否将 team.json 中的 Team Owner 也添加到授权服务
|
||||||
|
# 开启后,运行时会自动将 team.json 中的 Owner 账号也进行授权入库
|
||||||
|
# 注意: 请确保 Team Owner 邮箱可以接收验证码
|
||||||
|
include_team_owners = false
|
||||||
|
|
||||||
|
# ==================== CRS 服务配置 ====================
|
||||||
|
# CRS (Central Registration Service) 用于管理注册账号的中心服务
|
||||||
|
[crs]
|
||||||
|
# CRS API 接口地址
|
||||||
|
api_base = "https://your-crs-service.com"
|
||||||
|
# 管理员令牌,用于调用 CRS 管理接口
|
||||||
|
admin_token = "your-admin-token"
|
||||||
|
|
||||||
|
# ==================== CPA 服务配置 ====================
|
||||||
|
# CPA (Codex/Copilot Authorization) 用于 Copilot 授权服务
|
||||||
|
# 仅当 auth_provider = "cpa" 时生效
|
||||||
|
[cpa]
|
||||||
|
# CPA API 接口地址
|
||||||
|
api_base = "http://your-cpa-service:8317"
|
||||||
|
# 管理面板密码 (注意: 是密码,不是 token)
|
||||||
|
admin_password = "your-admin-password"
|
||||||
|
# 授权状态轮询间隔 (秒)
|
||||||
|
poll_interval = 2
|
||||||
|
# 授权状态轮询最大次数
|
||||||
|
poll_max_retries = 30
|
||||||
|
# 是否使用 WebUI 模式 (推荐保持 true)
|
||||||
|
is_webui = true
|
||||||
|
|
||||||
|
# ==================== S2A (Sub2API) 服务配置 ====================
|
||||||
|
# Sub2API 用于 OpenAI OAuth 授权和账号入库
|
||||||
|
# 仅当 auth_provider = "s2a" 时生效
|
||||||
|
[s2a]
|
||||||
|
# S2A API 接口地址
|
||||||
|
api_base = "https://your-sub2api-service.com/api/v1"
|
||||||
|
# Admin API Key (推荐,从系统设置中生成,永久有效)
|
||||||
|
admin_key = ""
|
||||||
|
# JWT Token (备选,从登录接口获取,有过期时间)
|
||||||
|
admin_token = ""
|
||||||
|
# 账号并发数
|
||||||
|
concurrency = 5
|
||||||
|
# 账号优先级
|
||||||
|
priority = 50
|
||||||
|
# 分组 ID 列表 (留空使用默认分组)
|
||||||
|
group_ids = []
|
||||||
|
# 分组名称列表 (优先使用 group_ids,如果未配置则通过名称查询 ID)
|
||||||
|
group_names = []
|
||||||
|
|
||||||
|
# ==================== 账号配置 ====================
|
||||||
|
[account]
|
||||||
|
# 注册账号的默认密码 (需符合 OpenAI 密码要求: 至少8位,包含大小写字母、数字、特殊字符)
|
||||||
|
default_password = "YourSecurePassword@2025"
|
||||||
|
# 每个 Team 下创建的账号数量
|
||||||
|
accounts_per_team = 4
|
||||||
|
|
||||||
|
# ==================== 注册配置 ====================
|
||||||
|
[register]
|
||||||
|
# 注册时使用的用户名 (实际会使用随机生成的英文名)
|
||||||
|
name = "test"
|
||||||
|
|
||||||
|
# 注册时使用的生日信息
|
||||||
|
[register.birthday]
|
||||||
|
year = "2000"
|
||||||
|
month = "01"
|
||||||
|
day = "01"
|
||||||
|
|
||||||
|
# ==================== 请求配置 ====================
|
||||||
|
[request]
|
||||||
|
# HTTP 请求超时时间 (秒)
|
||||||
|
timeout = 30
|
||||||
|
# 浏览器 User-Agent 字符串
|
||||||
|
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
# ==================== 验证码配置 ====================
|
||||||
|
[verification]
|
||||||
|
# 等待验证码的总超时时间 (秒)
|
||||||
|
timeout = 60
|
||||||
|
# 轮询邮箱的间隔时间 (秒)
|
||||||
|
interval = 3
|
||||||
|
# 最大重试次数 (总等待时间 = interval * max_retries)
|
||||||
|
max_retries = 20
|
||||||
|
|
||||||
|
# ==================== 浏览器配置 ====================
|
||||||
|
[browser]
|
||||||
|
# 等待页面元素的超时时间 (秒)
|
||||||
|
wait_timeout = 60
|
||||||
|
# 短等待时间,用于快速检查 (秒)
|
||||||
|
short_wait = 10
|
||||||
|
# 无头模式 (服务器运行时设为 true)
|
||||||
|
headless = false
|
||||||
|
|
||||||
|
# ==================== 代理配置 ====================
|
||||||
|
# 是否启用代理 (默认关闭)
|
||||||
|
proxy_enabled = false
|
||||||
|
|
||||||
|
# 支持配置多个代理,程序会轮换使用
|
||||||
|
# type: 代理类型 (socks5/http/https)
|
||||||
|
# host: 代理服务器地址
|
||||||
|
# port: 代理端口
|
||||||
|
# username/password: 代理认证信息 (可选)
|
||||||
|
|
||||||
|
# [[proxies]]
|
||||||
|
# type = "socks5"
|
||||||
|
# host = "127.0.0.1"
|
||||||
|
# port = 1080
|
||||||
|
# username = ""
|
||||||
|
# password = ""
|
||||||
|
|
||||||
|
# [[proxies]]
|
||||||
|
# type = "http"
|
||||||
|
# host = "proxy.example.com"
|
||||||
|
# port = 8080
|
||||||
|
# username = "user"
|
||||||
|
# password = "pass"
|
||||||
|
|
||||||
|
# ==================== 文件配置 ====================
|
||||||
|
[files]
|
||||||
|
# 导出账号信息的 CSV 文件路径
|
||||||
|
csv_file = "accounts.csv"
|
||||||
|
# Team 注册进度追踪文件路径
|
||||||
|
tracker_file = "team_tracker.json"
|
||||||
|
|
||||||
|
# ==================== Telegram Bot 配置 ====================
|
||||||
|
# 通过 Telegram Bot 远程控制和监控任务
|
||||||
|
[telegram]
|
||||||
|
# 是否启用 Telegram Bot
|
||||||
|
enabled = false
|
||||||
|
# Bot Token (通过 @BotFather 创建获取)
|
||||||
|
bot_token = "your-bot-token"
|
||||||
|
# 授权管理员的 Chat ID 列表 (通过 @userinfobot 获取)
|
||||||
|
admin_chat_ids = [123456789]
|
||||||
|
# 任务完成时发送通知
|
||||||
|
notify_on_complete = true
|
||||||
|
# 任务出错时发送通知
|
||||||
|
notify_on_error = true
|
||||||
|
# 定期检查账号存货间隔 (秒),0 表示禁用
|
||||||
|
check_interval = 3600
|
||||||
|
# 低库存预警阈值 (正常账号数低于此值时预警)
|
||||||
|
low_stock_threshold = 10
|
||||||
309
cpa_service.py
Normal file
309
cpa_service.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# ==================== CPA 服务模块 ====================
|
||||||
|
# 处理 CPA 系统相关功能 (Codex/Copilot Authorization)
|
||||||
|
#
|
||||||
|
# CPA 与 CRS 的关键差异:
|
||||||
|
# - 认证方式: CPA 使用 Bearer + 管理面板密码,CRS 使用 Bearer + Token
|
||||||
|
# - 会话标识: CPA 使用 state,CRS 使用 session_id
|
||||||
|
# - 授权流程: CPA 提交回调 URL 后轮询状态,CRS 直接交换 code 获取 tokens
|
||||||
|
# - 账号入库: CPA 后台自动处理,CRS 需手动调用 add_account
|
||||||
|
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CPA_API_BASE,
|
||||||
|
CPA_ADMIN_PASSWORD,
|
||||||
|
CPA_POLL_INTERVAL,
|
||||||
|
CPA_POLL_MAX_RETRIES,
|
||||||
|
CPA_IS_WEBUI,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
USER_AGENT,
|
||||||
|
)
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_with_retry():
|
||||||
|
"""创建带重试机制的 HTTP Session"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=5,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "POST", "OPTIONS"]
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
http_session = create_session_with_retry()
|
||||||
|
|
||||||
|
|
||||||
|
def build_cpa_headers() -> dict:
|
||||||
|
"""构建 CPA API 请求的 Headers
|
||||||
|
|
||||||
|
注意: CPA 使用 Bearer + 管理面板密码 进行认证,不是 Token
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"accept": "application/json",
|
||||||
|
"authorization": f"Bearer {CPA_ADMIN_PASSWORD}",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": USER_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cpa_verify_connection() -> tuple[bool, str]:
|
||||||
|
"""验证 CPA 服务连接和密码有效性
|
||||||
|
|
||||||
|
在程序启动时调用,确保配置正确,避免运行中途出现错误
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid, message)
|
||||||
|
- is_valid: 连接是否有效
|
||||||
|
- message: 验证结果描述
|
||||||
|
"""
|
||||||
|
# 检查配置是否完整
|
||||||
|
if not CPA_API_BASE:
|
||||||
|
return False, "CPA_API_BASE 未配置"
|
||||||
|
|
||||||
|
if not CPA_ADMIN_PASSWORD:
|
||||||
|
return False, "CPA_ADMIN_PASSWORD 未配置"
|
||||||
|
|
||||||
|
headers = build_cpa_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用获取授权 URL 接口测试连接
|
||||||
|
response = http_session.get(
|
||||||
|
f"{CPA_API_BASE}/v0/management/codex-auth-url",
|
||||||
|
headers=headers,
|
||||||
|
params={"is_webui": str(CPA_IS_WEBUI).lower()},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("url") and result.get("state"):
|
||||||
|
return True, "服务连接正常"
|
||||||
|
else:
|
||||||
|
return True, "服务连接正常 (响应格式可能有变化)"
|
||||||
|
|
||||||
|
elif response.status_code == 401:
|
||||||
|
return False, "管理面板密码无效 (HTTP 401 Unauthorized)"
|
||||||
|
|
||||||
|
elif response.status_code == 403:
|
||||||
|
return False, "权限不足 (HTTP 403 Forbidden)"
|
||||||
|
|
||||||
|
else:
|
||||||
|
return False, f"CPA 服务异常 (HTTP {response.status_code})"
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return False, f"CPA 服务连接超时 ({CPA_API_BASE})"
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, f"无法连接到 CPA 服务 ({CPA_API_BASE})"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"验证异常: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def cpa_generate_auth_url() -> tuple[str, str]:
|
||||||
|
"""获取 Codex 授权 URL
|
||||||
|
|
||||||
|
调用 GET /v0/management/codex-auth-url?is_webui=true
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (auth_url, state) 或 (None, None)
|
||||||
|
- auth_url: 授权跳转地址
|
||||||
|
- state: 会话标识 (类似 CRS 的 session_id)
|
||||||
|
"""
|
||||||
|
headers = build_cpa_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(
|
||||||
|
f"{CPA_API_BASE}/v0/management/codex-auth-url",
|
||||||
|
headers=headers,
|
||||||
|
params={"is_webui": str(CPA_IS_WEBUI).lower()},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
auth_url = result.get("url")
|
||||||
|
state = result.get("state")
|
||||||
|
|
||||||
|
if auth_url and state:
|
||||||
|
log.success(f"生成 CPA 授权 URL 成功 (State: {state[:16]}...)")
|
||||||
|
return auth_url, state
|
||||||
|
else:
|
||||||
|
log.error("CPA 响应缺少 url 或 state 字段")
|
||||||
|
log.error(f"响应内容: {result}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
log.error(f"生成 CPA 授权 URL 失败: HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
log.error(f"响应: {response.text[:200]}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"CPA API 异常: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def cpa_submit_callback(redirect_url: str) -> bool:
|
||||||
|
"""提交 OAuth 回调 URL
|
||||||
|
|
||||||
|
调用 POST /v0/management/oauth-callback
|
||||||
|
请求体: {"provider": "codex", "redirect_url": "完整的回调URL"}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redirect_url: 完整的回调 URL (包含 code, scope, state 参数)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否提交成功
|
||||||
|
"""
|
||||||
|
headers = build_cpa_headers()
|
||||||
|
payload = {
|
||||||
|
"provider": "codex",
|
||||||
|
"redirect_url": redirect_url
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{CPA_API_BASE}/v0/management/oauth-callback",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
log.success("CPA 回调 URL 提交成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
log.error(f"CPA 回调提交失败: HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
error_detail = response.json()
|
||||||
|
log.error(f"错误详情: {error_detail}")
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
log.error(f"响应: {response.text[:200]}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"CPA 提交回调异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cpa_check_auth_status(state: str) -> tuple[bool, str]:
|
||||||
|
"""检查授权状态
|
||||||
|
|
||||||
|
调用 GET /v0/management/get-auth-status?state=<state>
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: 会话标识
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_success, status_message)
|
||||||
|
- is_success: 授权是否成功
|
||||||
|
- status_message: 状态描述
|
||||||
|
"""
|
||||||
|
headers = build_cpa_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(
|
||||||
|
f"{CPA_API_BASE}/v0/management/get-auth-status",
|
||||||
|
headers=headers,
|
||||||
|
params={"state": state},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
status = result.get("status", "")
|
||||||
|
|
||||||
|
if status == "ok":
|
||||||
|
return True, "授权成功"
|
||||||
|
else:
|
||||||
|
return False, f"状态: {status}"
|
||||||
|
|
||||||
|
return False, f"检查状态失败: HTTP {response.status_code}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"检查状态异常: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def cpa_poll_auth_status(state: str) -> bool:
|
||||||
|
"""轮询授权状态直到成功或超时
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: 会话标识
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 授权是否成功
|
||||||
|
"""
|
||||||
|
max_wait = CPA_POLL_INTERVAL * CPA_POLL_MAX_RETRIES
|
||||||
|
log.step(f"轮询 CPA 授权状态 (最多 {max_wait}s)...")
|
||||||
|
|
||||||
|
for attempt in range(CPA_POLL_MAX_RETRIES):
|
||||||
|
is_success, message = cpa_check_auth_status(state)
|
||||||
|
|
||||||
|
if is_success:
|
||||||
|
log.progress_clear()
|
||||||
|
log.success(f"CPA 授权成功: {message}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
log.progress_inline(f"[CPA轮询中... {attempt + 1}/{CPA_POLL_MAX_RETRIES}] {message}")
|
||||||
|
time.sleep(CPA_POLL_INTERVAL)
|
||||||
|
|
||||||
|
log.progress_clear()
|
||||||
|
log.error("CPA 授权状态轮询超时")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_callback_info(url: str) -> dict:
|
||||||
|
"""从回调 URL 中提取信息
|
||||||
|
|
||||||
|
CPA 回调 URL 格式: http://localhost:1455/auth/callback?code=xxx&scope=xxx&state=xxx
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: 回调 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"code": "...", "scope": "...", "state": "...", "full_url": "..."} 或空字典
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
return {
|
||||||
|
"code": params.get("code", [None])[0],
|
||||||
|
"scope": params.get("scope", [None])[0],
|
||||||
|
"state": params.get("state", [None])[0],
|
||||||
|
"full_url": url
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"解析 CPA 回调 URL 失败: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_cpa_callback_url(url: str) -> bool:
|
||||||
|
"""检查 URL 是否为 CPA 回调 URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: 要检查的 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否为 CPA 回调 URL
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
return "localhost:1455/auth/callback" in url and "code=" in url
|
||||||
374
crs_service.py
Normal file
374
crs_service.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
# ==================== CRS 服务模块 ====================
|
||||||
|
# 处理 CRS 系统相关功能 (Codex 授权、账号入库)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CRS_API_BASE,
|
||||||
|
CRS_ADMIN_TOKEN,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
USER_AGENT,
|
||||||
|
TEAMS,
|
||||||
|
)
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_with_retry():
|
||||||
|
"""创建带重试机制的 HTTP Session"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=5,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "POST", "OPTIONS"]
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
http_session = create_session_with_retry()
|
||||||
|
|
||||||
|
|
||||||
|
def build_crs_headers() -> dict:
|
||||||
|
"""构建 CRS API 请求的 Headers"""
|
||||||
|
return {
|
||||||
|
"accept": "*/*",
|
||||||
|
"authorization": f"Bearer {CRS_ADMIN_TOKEN}",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"origin": CRS_API_BASE,
|
||||||
|
"referer": f"{CRS_API_BASE}/admin-next/accounts",
|
||||||
|
"user-agent": USER_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def crs_verify_token() -> tuple[bool, str]:
|
||||||
|
"""验证 CRS Admin Token 有效性
|
||||||
|
|
||||||
|
在程序启动时调用,确保 Token 有效,避免运行中途出现 401 错误
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid, message)
|
||||||
|
- is_valid: Token 是否有效
|
||||||
|
- message: 验证结果描述
|
||||||
|
"""
|
||||||
|
# 检查配置是否完整
|
||||||
|
if not CRS_API_BASE:
|
||||||
|
return False, "CRS_API_BASE 未配置"
|
||||||
|
|
||||||
|
if not CRS_ADMIN_TOKEN:
|
||||||
|
return False, "CRS_ADMIN_TOKEN 未配置"
|
||||||
|
|
||||||
|
headers = build_crs_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用获取账号列表接口验证 Token (GET 请求,只读操作)
|
||||||
|
response = http_session.get(
|
||||||
|
f"{CRS_API_BASE}/admin/openai-accounts",
|
||||||
|
headers=headers,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
account_count = len(result.get("data", []))
|
||||||
|
return True, f"Token 有效 (CRS 中已有 {account_count} 个账号)"
|
||||||
|
else:
|
||||||
|
return False, f"API 返回失败: {result.get('message', 'Unknown error')}"
|
||||||
|
|
||||||
|
elif response.status_code == 401:
|
||||||
|
return False, "Token 无效或已过期 (HTTP 401 Unauthorized)"
|
||||||
|
|
||||||
|
elif response.status_code == 403:
|
||||||
|
return False, "Token 权限不足 (HTTP 403 Forbidden)"
|
||||||
|
|
||||||
|
else:
|
||||||
|
return False, f"CRS 服务异常 (HTTP {response.status_code})"
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return False, f"CRS 服务连接超时 ({CRS_API_BASE})"
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, f"无法连接到 CRS 服务 ({CRS_API_BASE})"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"验证异常: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def crs_generate_auth_url() -> tuple[str, str]:
|
||||||
|
"""生成 Codex 授权 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (auth_url, session_id) 或 (None, None)
|
||||||
|
"""
|
||||||
|
headers = build_crs_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{CRS_API_BASE}/admin/openai-accounts/generate-auth-url",
|
||||||
|
headers=headers,
|
||||||
|
json={},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
auth_url = result["data"]["authUrl"]
|
||||||
|
session_id = result["data"]["sessionId"]
|
||||||
|
log.success(f"生成授权 URL 成功 (Session: {session_id[:16]}...)")
|
||||||
|
return auth_url, session_id
|
||||||
|
|
||||||
|
log.error(f"生成授权 URL 失败: HTTP {response.status_code}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"CRS API 异常: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def crs_exchange_code(code: str, session_id: str) -> dict:
|
||||||
|
"""用授权码换取 tokens
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: 授权码
|
||||||
|
session_id: 会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: codex_data 或 None
|
||||||
|
"""
|
||||||
|
headers = build_crs_headers()
|
||||||
|
payload = {"code": code, "sessionId": session_id}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{CRS_API_BASE}/admin/openai-accounts/exchange-code",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
log.success("授权码交换成功")
|
||||||
|
return result["data"]
|
||||||
|
|
||||||
|
log.error(f"授权码交换失败: HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"CRS 交换异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def crs_add_account(email: str, codex_data: dict) -> dict:
|
||||||
|
"""将账号添加到 CRS 账号池
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
codex_data: Codex 授权数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: CRS 账号数据 或 None
|
||||||
|
"""
|
||||||
|
headers = build_crs_headers()
|
||||||
|
payload = {
|
||||||
|
"name": email,
|
||||||
|
"description": "",
|
||||||
|
"accountType": "shared",
|
||||||
|
"proxy": None,
|
||||||
|
"openaiOauth": {
|
||||||
|
"idToken": codex_data.get("tokens", {}).get("idToken"),
|
||||||
|
"accessToken": codex_data.get("tokens", {}).get("accessToken"),
|
||||||
|
"refreshToken": codex_data.get("tokens", {}).get("refreshToken"),
|
||||||
|
"expires_in": codex_data.get("tokens", {}).get("expires_in", 864000)
|
||||||
|
},
|
||||||
|
"accountInfo": codex_data.get("accountInfo", {}),
|
||||||
|
"priority": 50
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{CRS_API_BASE}/admin/openai-accounts",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
account_id = result.get("data", {}).get("id")
|
||||||
|
log.success(f"账号添加到 CRS 成功 (ID: {account_id})")
|
||||||
|
return result["data"]
|
||||||
|
|
||||||
|
log.error(f"添加到 CRS 失败: HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"CRS 添加账号异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_code_from_url(url: str) -> str:
|
||||||
|
"""从回调 URL 中提取授权码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: 回调 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 授权码 或 None
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
code = params.get("code", [None])[0]
|
||||||
|
return code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"解析 URL 失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def crs_get_accounts() -> list:
|
||||||
|
"""获取 CRS 中的所有账号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 账号列表
|
||||||
|
"""
|
||||||
|
headers = build_crs_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(
|
||||||
|
f"{CRS_API_BASE}/admin/openai-accounts",
|
||||||
|
headers=headers,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
return result.get("data", [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"获取 CRS 账号列表异常: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def crs_check_account_exists(email: str) -> bool:
|
||||||
|
"""检查账号是否已在 CRS 中
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否存在
|
||||||
|
"""
|
||||||
|
accounts = crs_get_accounts()
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
if account.get("name", "").lower() == email.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def crs_add_team_owner(team_data: dict) -> dict:
|
||||||
|
"""将 Team 管理员账号添加到 CRS
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_data: team.json 中的单个 team 数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: CRS 账号数据 或 None
|
||||||
|
"""
|
||||||
|
email = team_data.get("user", {}).get("email", "")
|
||||||
|
access_token = team_data.get("accessToken", "")
|
||||||
|
|
||||||
|
if not email or not access_token:
|
||||||
|
log.warning(f"Team 数据不完整,跳过: {email}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
if crs_check_account_exists(email):
|
||||||
|
log.info(f"账号已存在于 CRS: {email}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = build_crs_headers()
|
||||||
|
payload = {
|
||||||
|
"name": email,
|
||||||
|
"description": "Team Owner (from team.json)",
|
||||||
|
"accountType": "shared",
|
||||||
|
"proxy": None,
|
||||||
|
"openaiOauth": {
|
||||||
|
"accessToken": access_token,
|
||||||
|
"refreshToken": "", # team.json 中没有 refreshToken
|
||||||
|
"idToken": "",
|
||||||
|
"expires_in": 864000
|
||||||
|
},
|
||||||
|
"accountInfo": {
|
||||||
|
"user_id": team_data.get("user", {}).get("id", ""),
|
||||||
|
"email": email,
|
||||||
|
"plan_type": team_data.get("account", {}).get("planType", "team"),
|
||||||
|
"organization_id": team_data.get("account", {}).get("organizationId", ""),
|
||||||
|
},
|
||||||
|
"priority": 50
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{CRS_API_BASE}/admin/openai-accounts",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
account_id = result.get("data", {}).get("id")
|
||||||
|
log.success(f"Team Owner 添加到 CRS: {email} (ID: {account_id})")
|
||||||
|
return result["data"]
|
||||||
|
|
||||||
|
log.error(f"添加 Team Owner 到 CRS 失败: {email} - HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"CRS 添加 Team Owner 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def crs_sync_team_owners() -> int:
|
||||||
|
"""同步 team.json 中的所有 Team 管理员到 CRS
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 成功添加的数量
|
||||||
|
"""
|
||||||
|
if not INCLUDE_TEAM_OWNERS:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not TEAMS:
|
||||||
|
log.warning("team.json 为空,无 Team Owner 可同步")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
log.info(f"开始同步 {len(TEAMS)} 个 Team Owner 到 CRS...", icon="sync")
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for team in TEAMS:
|
||||||
|
raw_data = team.get("raw", {})
|
||||||
|
if raw_data:
|
||||||
|
result = crs_add_team_owner(raw_data)
|
||||||
|
if result:
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
log.info(f"Team Owner 同步完成: {success_count}/{len(TEAMS)}", icon="sync")
|
||||||
|
return success_count
|
||||||
640
email_service.py
Normal file
640
email_service.py
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
# ==================== 邮箱服务模块 ====================
|
||||||
|
# 处理邮箱创建、验证码获取等功能 (支持多种邮箱系统)
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import requests
|
||||||
|
from typing import Callable, TypeVar, Optional, Any
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
EMAIL_API_BASE,
|
||||||
|
EMAIL_API_AUTH,
|
||||||
|
EMAIL_ROLE,
|
||||||
|
DEFAULT_PASSWORD,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
VERIFICATION_CODE_INTERVAL,
|
||||||
|
VERIFICATION_CODE_MAX_RETRIES,
|
||||||
|
get_random_domain,
|
||||||
|
EMAIL_PROVIDER,
|
||||||
|
GPTMAIL_API_BASE,
|
||||||
|
GPTMAIL_API_KEY,
|
||||||
|
GPTMAIL_PREFIX,
|
||||||
|
GPTMAIL_DOMAINS,
|
||||||
|
get_random_gptmail_domain,
|
||||||
|
)
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_with_retry():
|
||||||
|
"""创建带重试机制的 HTTP Session"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=5,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "POST", "OPTIONS"]
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# 全局 HTTP Session
|
||||||
|
http_session = create_session_with_retry()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 通用轮询重试工具 ====================
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
class PollResult:
|
||||||
|
"""轮询结果"""
|
||||||
|
|
||||||
|
def __init__(self, success: bool, data: Any = None, error: str = None):
|
||||||
|
self.success = success
|
||||||
|
self.data = data
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
|
def poll_with_retry(
|
||||||
|
fetch_func: Callable[[], Optional[T]],
|
||||||
|
check_func: Callable[[T], Optional[Any]],
|
||||||
|
max_retries: int = None,
|
||||||
|
interval: int = None,
|
||||||
|
fast_retries: int = 5,
|
||||||
|
fast_interval: int = 1,
|
||||||
|
description: str = "轮询",
|
||||||
|
on_progress: Callable[[float], None] = None,
|
||||||
|
) -> PollResult:
|
||||||
|
"""通用轮询重试函数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fetch_func: 获取数据的函数,返回原始数据或 None
|
||||||
|
check_func: 检查数据的函数,返回提取的结果或 None
|
||||||
|
max_retries: 最大重试次数
|
||||||
|
interval: 正常轮询间隔 (秒)
|
||||||
|
fast_retries: 快速轮询次数 (前 N 次使用快速间隔)
|
||||||
|
fast_interval: 快速轮询间隔 (秒)
|
||||||
|
description: 描述信息 (用于日志)
|
||||||
|
on_progress: 进度回调函数,参数为已用时间 (秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PollResult: 轮询结果
|
||||||
|
"""
|
||||||
|
if max_retries is None:
|
||||||
|
max_retries = VERIFICATION_CODE_MAX_RETRIES
|
||||||
|
if interval is None:
|
||||||
|
interval = VERIFICATION_CODE_INTERVAL
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
progress_shown = False
|
||||||
|
|
||||||
|
for i in range(max_retries):
|
||||||
|
try:
|
||||||
|
# 获取数据
|
||||||
|
data = fetch_func()
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
# 检查数据
|
||||||
|
result = check_func(data)
|
||||||
|
if result is not None:
|
||||||
|
if progress_shown:
|
||||||
|
log.progress_clear()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return PollResult(success=True, data=result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if progress_shown:
|
||||||
|
log.progress_clear()
|
||||||
|
progress_shown = False
|
||||||
|
log.warning(f"{description}异常: {e}")
|
||||||
|
|
||||||
|
if i < max_retries - 1:
|
||||||
|
# 动态间隔: 前 fast_retries 次使用快速间隔
|
||||||
|
wait_time = fast_interval if i < fast_retries else interval
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if on_progress:
|
||||||
|
on_progress(elapsed)
|
||||||
|
else:
|
||||||
|
log.progress_inline(f"[等待中... {elapsed:.0f}s]")
|
||||||
|
progress_shown = True
|
||||||
|
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
if progress_shown:
|
||||||
|
log.progress_clear()
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
return PollResult(success=False, error=f"超时 ({elapsed:.0f}s)")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== GPTMail 临时邮箱服务 ====================
|
||||||
|
class GPTMailService:
|
||||||
|
"""GPTMail 临时邮箱服务"""
|
||||||
|
|
||||||
|
def __init__(self, api_base: str = None, api_key: str = None):
|
||||||
|
self.api_base = api_base or GPTMAIL_API_BASE
|
||||||
|
self.api_key = api_key or GPTMAIL_API_KEY
|
||||||
|
self.headers = {
|
||||||
|
"X-API-Key": self.api_key,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_email(self, prefix: str = None, domain: str = None) -> tuple[str, str]:
|
||||||
|
"""生成临时邮箱地址
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: 邮箱前缀 (可选)
|
||||||
|
domain: 域名 (可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (email, error) - 邮箱地址和错误信息
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/api/generate-email"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if prefix or domain:
|
||||||
|
payload = {}
|
||||||
|
if prefix:
|
||||||
|
payload["prefix"] = prefix
|
||||||
|
if domain:
|
||||||
|
payload["domain"] = domain
|
||||||
|
response = http_session.post(url, headers=self.headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
else:
|
||||||
|
response = http_session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
email = data.get("data", {}).get("email", "")
|
||||||
|
log.success(f"GPTMail 生成邮箱: {email}")
|
||||||
|
return email, None
|
||||||
|
else:
|
||||||
|
error = data.get("error", "Unknown error")
|
||||||
|
log.error(f"GPTMail 生成邮箱失败: {error}")
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"GPTMail 生成邮箱异常: {e}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
def get_emails(self, email: str) -> tuple[list, str]:
|
||||||
|
"""获取邮箱的邮件列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (emails, error) - 邮件列表和错误信息
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/api/emails"
|
||||||
|
params = {"email": email}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(url, headers=self.headers, params=params, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
emails = data.get("data", {}).get("emails", [])
|
||||||
|
return emails, None
|
||||||
|
else:
|
||||||
|
error = data.get("error", "Unknown error")
|
||||||
|
return [], error
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"GPTMail 获取邮件列表异常: {e}")
|
||||||
|
return [], str(e)
|
||||||
|
|
||||||
|
def get_email_detail(self, email_id: str) -> tuple[dict, str]:
|
||||||
|
"""获取单封邮件详情
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: 邮件ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (email_detail, error) - 邮件详情和错误信息
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/api/email/{email_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
return data.get("data", {}), None
|
||||||
|
else:
|
||||||
|
error = data.get("error", "Unknown error")
|
||||||
|
return {}, error
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"GPTMail 获取邮件详情异常: {e}")
|
||||||
|
return {}, str(e)
|
||||||
|
|
||||||
|
def delete_email(self, email_id: str) -> tuple[bool, str]:
|
||||||
|
"""删除单封邮件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: 邮件ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error)
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/api/email/{email_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.delete(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
return True, None
|
||||||
|
else:
|
||||||
|
return False, data.get("error", "Unknown error")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def clear_inbox(self, email: str) -> tuple[int, str]:
|
||||||
|
"""清空邮箱
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (deleted_count, error)
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/api/emails/clear"
|
||||||
|
params = {"email": email}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.delete(url, headers=self.headers, params=params, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
count = data.get("data", {}).get("count", 0)
|
||||||
|
return count, None
|
||||||
|
else:
|
||||||
|
return 0, data.get("error", "Unknown error")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 0, str(e)
|
||||||
|
|
||||||
|
def get_verification_code(self, email: str, max_retries: int = None, interval: int = None) -> tuple[str, str, str]:
|
||||||
|
"""从邮箱获取验证码 (使用通用轮询重试)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
max_retries: 最大重试次数
|
||||||
|
interval: 基础轮询间隔 (秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (code, error, email_time) - 验证码、错误信息、邮件时间
|
||||||
|
"""
|
||||||
|
log.info(f"GPTMail 等待验证码邮件: {email}", icon="email")
|
||||||
|
|
||||||
|
# 用于存储邮件时间的闭包变量
|
||||||
|
email_time_holder = [None]
|
||||||
|
|
||||||
|
def fetch_emails():
|
||||||
|
"""获取邮件列表"""
|
||||||
|
emails, error = self.get_emails(email)
|
||||||
|
return emails if emails else None
|
||||||
|
|
||||||
|
def check_for_code(emails):
|
||||||
|
"""检查邮件中是否有验证码"""
|
||||||
|
for email_item in emails:
|
||||||
|
subject = email_item.get("subject", "")
|
||||||
|
content = email_item.get("content", "")
|
||||||
|
email_time_holder[0] = email_item.get("created_at", "")
|
||||||
|
|
||||||
|
# 尝试从主题中提取验证码
|
||||||
|
code = self._extract_code(subject)
|
||||||
|
if code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
# 尝试从内容中提取验证码
|
||||||
|
code = self._extract_code(content)
|
||||||
|
if code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 使用通用轮询函数
|
||||||
|
result = poll_with_retry(
|
||||||
|
fetch_func=fetch_emails,
|
||||||
|
check_func=check_for_code,
|
||||||
|
max_retries=max_retries,
|
||||||
|
interval=interval,
|
||||||
|
description="GPTMail 获取邮件"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
log.success(f"GPTMail 验证码获取成功: {result.data}")
|
||||||
|
return result.data, None, email_time_holder[0]
|
||||||
|
else:
|
||||||
|
log.error(f"GPTMail 验证码获取失败 ({result.error})")
|
||||||
|
return None, "未能获取验证码", None
|
||||||
|
|
||||||
|
def _extract_code(self, text: str) -> str:
|
||||||
|
"""从文本中提取验证码"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 尝试多种模式
|
||||||
|
patterns = [
|
||||||
|
r"代码为\s*(\d{6})",
|
||||||
|
r"code is\s*(\d{6})",
|
||||||
|
r"verification code[:\s]*(\d{6})",
|
||||||
|
r"验证码[::\s]*(\d{6})",
|
||||||
|
r"(\d{6})", # 最后尝试直接匹配6位数字
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 全局 GPTMail 服务实例
|
||||||
|
gptmail_service = GPTMailService()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 原有 KYX 邮箱服务 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def generate_random_email() -> str:
|
||||||
|
"""生成随机邮箱地址: {random_str}oaiteam@{random_domain}"""
|
||||||
|
random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||||
|
domain = get_random_domain()
|
||||||
|
email = f"{random_str}oaiteam@{domain}"
|
||||||
|
log.success(f"生成邮箱: {email}")
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
def create_email_user(email: str, password: str = None, role_name: str = None) -> tuple[bool, str]:
|
||||||
|
"""在邮箱平台创建用户 (与 main.py 一致)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
password: 密码,默认使用 DEFAULT_PASSWORD
|
||||||
|
role_name: 角色名,默认使用 EMAIL_ROLE
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, message)
|
||||||
|
"""
|
||||||
|
if password is None:
|
||||||
|
password = DEFAULT_PASSWORD
|
||||||
|
if role_name is None:
|
||||||
|
role_name = EMAIL_ROLE
|
||||||
|
|
||||||
|
url = f"{EMAIL_API_BASE}/addUser"
|
||||||
|
headers = {
|
||||||
|
"Authorization": EMAIL_API_AUTH,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"list": [{"email": email, "password": password, "roleName": role_name}]
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
log.info(f"创建邮箱用户: {email}", icon="email")
|
||||||
|
response = http_session.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
success = data.get("code") == 200
|
||||||
|
msg = data.get("message", "Unknown error")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
log.success("邮箱创建成功")
|
||||||
|
else:
|
||||||
|
log.warning(f"邮箱创建失败: {msg}")
|
||||||
|
|
||||||
|
return success, msg
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"邮箱创建异常: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def get_verification_code(email: str, max_retries: int = None, interval: int = None) -> tuple[str, str, str]:
|
||||||
|
"""从邮箱获取验证码 (使用通用轮询重试)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
max_retries: 最大重试次数
|
||||||
|
interval: 基础轮询间隔 (秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (code, error, email_time) - 验证码、错误信息、邮件时间
|
||||||
|
"""
|
||||||
|
url = f"{EMAIL_API_BASE}/emailList"
|
||||||
|
headers = {
|
||||||
|
"Authorization": EMAIL_API_AUTH,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
payload = {"toEmail": email}
|
||||||
|
|
||||||
|
log.info(f"等待验证码邮件: {email}", icon="email")
|
||||||
|
|
||||||
|
# 记录初始邮件数量,用于检测新邮件
|
||||||
|
initial_email_count = 0
|
||||||
|
try:
|
||||||
|
response = http_session.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") == 200:
|
||||||
|
initial_email_count = len(data.get("data", []))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 用于存储邮件时间的闭包变量
|
||||||
|
email_time_holder = [None]
|
||||||
|
|
||||||
|
def fetch_emails():
|
||||||
|
"""获取邮件列表"""
|
||||||
|
response = http_session.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") == 200:
|
||||||
|
emails = data.get("data", [])
|
||||||
|
# 只返回有新邮件时的数据
|
||||||
|
if emails and len(emails) > initial_email_count:
|
||||||
|
return emails
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_code_from_subject(subject: str) -> str:
|
||||||
|
"""从主题中提取验证码"""
|
||||||
|
patterns = [
|
||||||
|
r"代码为\s*(\d{6})",
|
||||||
|
r"code is\s*(\d{6})",
|
||||||
|
r"(\d{6})",
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, subject, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_for_code(emails):
|
||||||
|
"""检查邮件中是否有验证码"""
|
||||||
|
latest_email = emails[0]
|
||||||
|
subject = latest_email.get("subject", "")
|
||||||
|
email_time_holder[0] = latest_email.get("createTime", "")
|
||||||
|
|
||||||
|
code = extract_code_from_subject(subject)
|
||||||
|
return code
|
||||||
|
|
||||||
|
# 使用通用轮询函数
|
||||||
|
result = poll_with_retry(
|
||||||
|
fetch_func=fetch_emails,
|
||||||
|
check_func=check_for_code,
|
||||||
|
max_retries=max_retries,
|
||||||
|
interval=interval,
|
||||||
|
description="获取邮件"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
log.success(f"验证码获取成功: {result.data}")
|
||||||
|
return result.data, None, email_time_holder[0]
|
||||||
|
else:
|
||||||
|
log.error(f"验证码获取失败 ({result.error})")
|
||||||
|
return None, "未能获取验证码", None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_email_content(email: str) -> list:
|
||||||
|
"""获取邮箱中的邮件列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 邮件列表
|
||||||
|
"""
|
||||||
|
url = f"{EMAIL_API_BASE}/emailList"
|
||||||
|
headers = {
|
||||||
|
"Authorization": EMAIL_API_AUTH,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
payload = {"toEmail": email}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 200:
|
||||||
|
return data.get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"获取邮件列表异常: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def batch_create_emails(count: int = 4) -> list:
|
||||||
|
"""批量创建邮箱 (根据 EMAIL_PROVIDER 配置自动选择邮箱系统)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: 创建数量
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{"email": "...", "password": "..."}, ...]
|
||||||
|
"""
|
||||||
|
accounts = []
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
email, password = unified_create_email()
|
||||||
|
|
||||||
|
if email:
|
||||||
|
accounts.append({
|
||||||
|
"email": email,
|
||||||
|
"password": password
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
log.warning(f"跳过第 {i+1} 个邮箱创建")
|
||||||
|
|
||||||
|
log.info(f"邮箱创建完成: {len(accounts)}/{count}", icon="email")
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 统一邮箱接口 (根据配置自动选择) ====================
|
||||||
|
|
||||||
|
def unified_generate_email() -> str:
|
||||||
|
"""统一生成邮箱地址接口 (根据 EMAIL_PROVIDER 配置自动选择)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 邮箱地址
|
||||||
|
"""
|
||||||
|
if EMAIL_PROVIDER == "gptmail":
|
||||||
|
# 生成随机前缀 + oaiteam 后缀,确保不重复
|
||||||
|
random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||||
|
prefix = f"{random_str}-oaiteam"
|
||||||
|
domain = get_random_gptmail_domain() or None
|
||||||
|
email, error = gptmail_service.generate_email(prefix=prefix, domain=domain)
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
log.warning(f"GPTMail 生成失败,回退到 KYX: {error}")
|
||||||
|
|
||||||
|
# 默认使用 KYX 系统
|
||||||
|
return generate_random_email()
|
||||||
|
|
||||||
|
|
||||||
|
def unified_create_email() -> tuple[str, str]:
|
||||||
|
"""统一创建邮箱接口 (根据 EMAIL_PROVIDER 配置自动选择)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (email, password)
|
||||||
|
"""
|
||||||
|
if EMAIL_PROVIDER == "gptmail":
|
||||||
|
# 生成随机前缀 + oaiteam 后缀,确保不重复
|
||||||
|
random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||||
|
prefix = f"{random_str}-oaiteam"
|
||||||
|
domain = get_random_gptmail_domain() or None
|
||||||
|
email, error = gptmail_service.generate_email(prefix=prefix, domain=domain)
|
||||||
|
if email:
|
||||||
|
# GPTMail 不需要密码,但为了接口一致性返回默认密码
|
||||||
|
return email, DEFAULT_PASSWORD
|
||||||
|
log.warning(f"GPTMail 生成失败,回退到 KYX: {error}")
|
||||||
|
|
||||||
|
# 默认使用 KYX 系统
|
||||||
|
email = generate_random_email()
|
||||||
|
success, msg = create_email_user(email, DEFAULT_PASSWORD)
|
||||||
|
if success or "已存在" in msg:
|
||||||
|
return email, DEFAULT_PASSWORD
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def unified_get_verification_code(email: str, max_retries: int = None, interval: int = None) -> tuple[str, str, str]:
|
||||||
|
"""统一获取验证码接口 (根据 EMAIL_PROVIDER 配置自动选择)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
max_retries: 最大重试次数
|
||||||
|
interval: 轮询间隔 (秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (code, error, email_time) - 验证码、错误信息、邮件时间
|
||||||
|
"""
|
||||||
|
if EMAIL_PROVIDER == "gptmail":
|
||||||
|
return gptmail_service.get_verification_code(email, max_retries, interval)
|
||||||
|
|
||||||
|
# 默认使用 KYX 系统
|
||||||
|
return get_verification_code(email, max_retries, interval)
|
||||||
|
|
||||||
|
|
||||||
|
def unified_fetch_emails(email: str) -> list:
|
||||||
|
"""统一获取邮件列表接口 (根据 EMAIL_PROVIDER 配置自动选择)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 邮件列表
|
||||||
|
"""
|
||||||
|
if EMAIL_PROVIDER == "gptmail":
|
||||||
|
emails, error = gptmail_service.get_emails(email)
|
||||||
|
return emails
|
||||||
|
|
||||||
|
# 默认使用 KYX 系统
|
||||||
|
return fetch_email_content(email)
|
||||||
300
logger.py
Normal file
300
logger.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# ==================== 日志模块 ====================
|
||||||
|
# 统一的日志输出,支持控制台和文件日志,带日志轮转
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 日志配置 ====================
|
||||||
|
LOG_DIR = Path(__file__).parent / "logs"
|
||||||
|
LOG_FILE = LOG_DIR / "app.log"
|
||||||
|
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
||||||
|
LOG_BACKUP_COUNT = 5 # 保留 5 个备份
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_log_dir():
|
||||||
|
"""确保日志目录存在"""
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ColoredFormatter(logging.Formatter):
|
||||||
|
"""带颜色的控制台日志格式化器"""
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
|
logging.DEBUG: "\033[90m", # 灰色
|
||||||
|
logging.INFO: "\033[0m", # 默认
|
||||||
|
logging.WARNING: "\033[93m", # 黄色
|
||||||
|
logging.ERROR: "\033[91m", # 红色
|
||||||
|
logging.CRITICAL: "\033[91m", # 红色
|
||||||
|
}
|
||||||
|
RESET = "\033[0m"
|
||||||
|
GREEN = "\033[92m" # 用于 success
|
||||||
|
BLUE = "\033[94m" # 用于 highlight
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
# 自定义 level 颜色
|
||||||
|
color = self.COLORS.get(record.levelno, self.RESET)
|
||||||
|
|
||||||
|
# 处理自定义的 success level
|
||||||
|
if hasattr(record, 'is_success') and record.is_success:
|
||||||
|
color = self.GREEN
|
||||||
|
|
||||||
|
# 处理自定义的 highlight level (蓝色)
|
||||||
|
if hasattr(record, 'is_highlight') and record.is_highlight:
|
||||||
|
color = self.BLUE
|
||||||
|
|
||||||
|
# 格式化时间
|
||||||
|
timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
# 获取图标
|
||||||
|
icon = getattr(record, 'icon', '')
|
||||||
|
if icon:
|
||||||
|
icon = f"{icon} "
|
||||||
|
|
||||||
|
# 构建消息
|
||||||
|
message = f"[{timestamp}] {color}{icon}{record.getMessage()}{self.RESET}"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
class FileFormatter(logging.Formatter):
|
||||||
|
"""文件日志格式化器 (不带颜色)"""
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
level = record.levelname.ljust(8)
|
||||||
|
icon = getattr(record, 'icon', '')
|
||||||
|
if icon:
|
||||||
|
icon = f"{icon} "
|
||||||
|
return f"[{timestamp}] [{level}] {icon}{record.getMessage()}"
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
"""统一日志输出 (基于 Python logging 模块)"""
|
||||||
|
|
||||||
|
# 日志级别
|
||||||
|
LEVEL_DEBUG = logging.DEBUG
|
||||||
|
LEVEL_INFO = logging.INFO
|
||||||
|
LEVEL_WARNING = logging.WARNING
|
||||||
|
LEVEL_ERROR = logging.ERROR
|
||||||
|
|
||||||
|
# 日志级别图标
|
||||||
|
ICONS = {
|
||||||
|
"info": "",
|
||||||
|
"success": "",
|
||||||
|
"warning": "",
|
||||||
|
"error": "",
|
||||||
|
"debug": "",
|
||||||
|
"start": "",
|
||||||
|
"browser": "",
|
||||||
|
"email": "",
|
||||||
|
"code": "",
|
||||||
|
"save": "",
|
||||||
|
"time": "",
|
||||||
|
"wait": "",
|
||||||
|
"account": "",
|
||||||
|
"team": "",
|
||||||
|
"auth": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, name: str = "app", use_color: bool = True, level: int = None,
|
||||||
|
enable_file_log: bool = True):
|
||||||
|
"""初始化日志器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 日志器名称
|
||||||
|
use_color: 是否使用颜色 (仅控制台)
|
||||||
|
level: 日志级别
|
||||||
|
enable_file_log: 是否启用文件日志
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.use_color = use_color
|
||||||
|
self.enable_file_log = enable_file_log
|
||||||
|
|
||||||
|
# 从环境变量读取日志级别,默认 INFO
|
||||||
|
if level is None:
|
||||||
|
env_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
|
level_map = {"DEBUG": logging.DEBUG, "INFO": logging.INFO,
|
||||||
|
"WARNING": logging.WARNING, "ERROR": logging.ERROR}
|
||||||
|
level = level_map.get(env_level, logging.INFO)
|
||||||
|
|
||||||
|
self.level = level
|
||||||
|
self._setup_logger()
|
||||||
|
|
||||||
|
def _setup_logger(self):
|
||||||
|
"""设置日志器"""
|
||||||
|
self._logger = logging.getLogger(self.name)
|
||||||
|
self._logger.setLevel(self.level)
|
||||||
|
self._logger.handlers.clear() # 清除已有的处理器
|
||||||
|
|
||||||
|
# 控制台处理器
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(self.level)
|
||||||
|
if self.use_color:
|
||||||
|
console_handler.setFormatter(ColoredFormatter())
|
||||||
|
else:
|
||||||
|
console_handler.setFormatter(FileFormatter())
|
||||||
|
self._logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# 文件处理器 (带轮转)
|
||||||
|
if self.enable_file_log:
|
||||||
|
try:
|
||||||
|
_ensure_log_dir()
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
LOG_FILE,
|
||||||
|
maxBytes=LOG_MAX_BYTES,
|
||||||
|
backupCount=LOG_BACKUP_COUNT,
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
file_handler.setLevel(self.level)
|
||||||
|
file_handler.setFormatter(FileFormatter())
|
||||||
|
self._logger.addHandler(file_handler)
|
||||||
|
except Exception as e:
|
||||||
|
# 文件日志初始化失败时继续使用控制台日志
|
||||||
|
print(f"[WARNING] 文件日志初始化失败: {e}")
|
||||||
|
|
||||||
|
def _get_icon(self, icon: str = None) -> str:
|
||||||
|
"""获取图标"""
|
||||||
|
if icon:
|
||||||
|
return self.ICONS.get(icon, icon)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def info(self, msg: str, icon: str = None, indent: int = 0):
|
||||||
|
"""信息日志"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': self._get_icon(icon)}
|
||||||
|
self._logger.info(f"{prefix}{msg}", extra=extra)
|
||||||
|
|
||||||
|
def success(self, msg: str, indent: int = 0):
|
||||||
|
"""成功日志"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': self._get_icon("success"), 'is_success': True}
|
||||||
|
self._logger.info(f"{prefix}{msg}", extra=extra)
|
||||||
|
|
||||||
|
def highlight(self, msg: str, icon: str = None, indent: int = 0):
|
||||||
|
"""高亮日志 (蓝色)"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': self._get_icon(icon), 'is_highlight': True}
|
||||||
|
self._logger.info(f"{prefix}{msg}", extra=extra)
|
||||||
|
|
||||||
|
def warning(self, msg: str, indent: int = 0):
|
||||||
|
"""警告日志"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': self._get_icon("warning")}
|
||||||
|
self._logger.warning(f"{prefix}{msg}", extra=extra)
|
||||||
|
|
||||||
|
def error(self, msg: str, indent: int = 0):
|
||||||
|
"""错误日志"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': self._get_icon("error")}
|
||||||
|
self._logger.error(f"{prefix}{msg}", extra=extra)
|
||||||
|
|
||||||
|
def debug(self, msg: str, indent: int = 0):
|
||||||
|
"""调试日志"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': self._get_icon("debug")}
|
||||||
|
self._logger.debug(f"{prefix}{msg}", extra=extra)
|
||||||
|
|
||||||
|
def step(self, msg: str, indent: int = 0):
|
||||||
|
"""步骤日志 (INFO 级别)"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': ''}
|
||||||
|
self._logger.info(f"{prefix}-> {msg}", extra=extra)
|
||||||
|
|
||||||
|
def verbose(self, msg: str, indent: int = 0):
|
||||||
|
"""详细日志 (DEBUG 级别)"""
|
||||||
|
prefix = " " * indent
|
||||||
|
extra = {'icon': ''}
|
||||||
|
self._logger.debug(f"{prefix}. {msg}", extra=extra)
|
||||||
|
|
||||||
|
def progress(self, current: int, total: int, msg: str = ""):
|
||||||
|
"""进度日志"""
|
||||||
|
pct = (current / total * 100) if total > 0 else 0
|
||||||
|
bar_len = 20
|
||||||
|
filled = int(bar_len * current / total) if total > 0 else 0
|
||||||
|
bar = "=" * filled + "-" * (bar_len - filled)
|
||||||
|
extra = {'icon': ''}
|
||||||
|
self._logger.info(f"[{bar}] {current}/{total} ({pct:.0f}%) {msg}", extra=extra)
|
||||||
|
|
||||||
|
def progress_inline(self, msg: str):
|
||||||
|
"""内联进度 (覆盖当前行)"""
|
||||||
|
print(f"\r{msg}" + " " * 10, end='', flush=True)
|
||||||
|
|
||||||
|
def progress_clear(self):
|
||||||
|
"""清除内联进度"""
|
||||||
|
print("\r" + " " * 50 + "\r", end='', flush=True)
|
||||||
|
|
||||||
|
def countdown(self, seconds: int, msg: str = "等待"):
|
||||||
|
"""倒计时显示 (同一行更新)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: 倒计时秒数
|
||||||
|
msg: 提示消息
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
for remaining in range(seconds, 0, -1):
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
print(f"\r[{timestamp}] {msg} {remaining}s... ", end='', flush=True)
|
||||||
|
time.sleep(1)
|
||||||
|
self.progress_clear()
|
||||||
|
|
||||||
|
def separator(self, char: str = "=", length: int = 60):
|
||||||
|
"""分隔线"""
|
||||||
|
extra = {'icon': ''}
|
||||||
|
self._logger.info(char * length, extra=extra)
|
||||||
|
|
||||||
|
def header(self, title: str):
|
||||||
|
"""标题"""
|
||||||
|
self.separator()
|
||||||
|
extra = {'icon': ''}
|
||||||
|
self._logger.info(f" {title}", extra=extra)
|
||||||
|
self.separator()
|
||||||
|
|
||||||
|
def section(self, title: str):
|
||||||
|
"""小节标题"""
|
||||||
|
extra = {'icon': ''}
|
||||||
|
self._logger.info("#" * 40, extra=extra)
|
||||||
|
self._logger.info(f"# {title}", extra=extra)
|
||||||
|
self._logger.info("#" * 40, extra=extra)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 配置日志辅助函数 ====================
|
||||||
|
def log_config_error(source: str, error: str, details: str = None):
|
||||||
|
"""记录配置加载错误
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: 配置来源 (如 config.toml, team.json)
|
||||||
|
error: 错误类型
|
||||||
|
details: 详细信息
|
||||||
|
"""
|
||||||
|
msg = f"配置加载失败 [{source}]: {error}"
|
||||||
|
if details:
|
||||||
|
msg += f" - {details}"
|
||||||
|
log.warning(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def log_config_warning(source: str, message: str):
|
||||||
|
"""记录配置警告
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: 配置来源
|
||||||
|
message: 警告信息
|
||||||
|
"""
|
||||||
|
log.warning(f"配置警告 [{source}]: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_config_info(source: str, message: str):
|
||||||
|
"""记录配置信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: 配置来源
|
||||||
|
message: 信息内容
|
||||||
|
"""
|
||||||
|
log.info(f"配置 [{source}]: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
# 全局日志实例
|
||||||
|
log = Logger()
|
||||||
14
pyproject.toml
Normal file
14
pyproject.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[project]
|
||||||
|
name = "oai-team-auto-provisioner"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "OpenAI Team 账号自动批量注册 & CRS 入库工具"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"drissionpage>=4.1.1.2",
|
||||||
|
"python-telegram-bot>=22.5",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
"rich>=14.2.0",
|
||||||
|
"setuptools>=80.9.0",
|
||||||
|
"tomli>=2.3.0",
|
||||||
|
]
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
drissionpage>=4.1.1.2
|
||||||
|
python-telegram-bot>=22.0
|
||||||
|
requests>=2.32.5
|
||||||
|
rich>=14.2.0
|
||||||
|
setuptools>=80.9.0
|
||||||
|
tomli>=2.3.0
|
||||||
818
run.py
Normal file
818
run.py
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
# ==================== 主入口文件 ====================
|
||||||
|
# ChatGPT Team 批量注册自动化 - 主程序
|
||||||
|
#
|
||||||
|
# 流程:
|
||||||
|
# 1. 检查未完成账号 (自动恢复)
|
||||||
|
# 2. 批量创建邮箱 (4个)
|
||||||
|
# 3. 一次性邀请到 Team
|
||||||
|
# 4. 逐个注册 OpenAI 账号
|
||||||
|
# 5. 逐个 Codex 授权
|
||||||
|
# 6. 逐个添加到 CRS
|
||||||
|
# 7. 切换下一个 Team
|
||||||
|
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
TEAMS, ACCOUNTS_PER_TEAM, DEFAULT_PASSWORD, AUTH_PROVIDER,
|
||||||
|
add_domain_to_blacklist, get_domain_from_email, is_email_blacklisted,
|
||||||
|
save_team_json, get_next_proxy
|
||||||
|
)
|
||||||
|
from email_service import batch_create_emails, unified_create_email
|
||||||
|
from team_service import batch_invite_to_team, print_team_summary, check_available_seats, invite_single_to_team, preload_all_account_ids
|
||||||
|
from crs_service import crs_add_account, crs_sync_team_owners, crs_verify_token
|
||||||
|
from cpa_service import cpa_verify_connection
|
||||||
|
from s2a_service import s2a_verify_connection
|
||||||
|
from browser_automation import register_and_authorize, login_and_authorize_with_otp, authorize_only, login_and_authorize_team_owner
|
||||||
|
from utils import (
|
||||||
|
save_to_csv,
|
||||||
|
load_team_tracker,
|
||||||
|
save_team_tracker,
|
||||||
|
add_account_with_password,
|
||||||
|
update_account_status,
|
||||||
|
remove_account_from_tracker,
|
||||||
|
get_incomplete_accounts,
|
||||||
|
get_all_incomplete_accounts,
|
||||||
|
print_summary,
|
||||||
|
Timer,
|
||||||
|
add_team_owners_to_tracker
|
||||||
|
)
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
# 进度更新 (Telegram Bot 使用,导入失败时忽略)
|
||||||
|
try:
|
||||||
|
from bot_notifier import progress_start, progress_update, progress_account_done, progress_finish
|
||||||
|
except ImportError:
|
||||||
|
# 如果没有 bot_notifier,使用空函数
|
||||||
|
def progress_start(team_name, total): pass
|
||||||
|
def progress_update(account=None, step=None): pass
|
||||||
|
def progress_account_done(email, success): pass
|
||||||
|
def progress_finish(): pass
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 全局状态 ====================
|
||||||
|
_tracker = None
|
||||||
|
_current_results = []
|
||||||
|
_shutdown_requested = False
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state():
|
||||||
|
"""保存当前状态 (用于退出时保存)"""
|
||||||
|
global _tracker
|
||||||
|
if _tracker:
|
||||||
|
log.info("保存状态...", icon="save")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
log.success("状态已保存到 team_tracker.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_handler(signum, frame):
|
||||||
|
"""处理 Ctrl+C 信号"""
|
||||||
|
global _shutdown_requested
|
||||||
|
if _shutdown_requested:
|
||||||
|
log.warning("强制退出...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_shutdown_requested = True
|
||||||
|
log.warning("收到中断信号,正在安全退出...")
|
||||||
|
_save_state()
|
||||||
|
|
||||||
|
if _current_results:
|
||||||
|
log.info("当前进度:")
|
||||||
|
print_summary(_current_results)
|
||||||
|
|
||||||
|
log.info("提示: 下次运行将自动从未完成的账号继续")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# 注册信号处理器
|
||||||
|
signal.signal(signal.SIGINT, _signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, _signal_handler)
|
||||||
|
atexit.register(_save_state)
|
||||||
|
|
||||||
|
|
||||||
|
def process_single_team(team: dict) -> tuple[list, list]:
|
||||||
|
"""处理单个 Team 的完整流程
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (处理结果列表, 待处理的 Owner 列表)
|
||||||
|
"""
|
||||||
|
global _tracker, _current_results, _shutdown_requested
|
||||||
|
|
||||||
|
results = []
|
||||||
|
team_name = team["name"]
|
||||||
|
|
||||||
|
# 只在 _tracker 为空时加载,避免覆盖已有的修改
|
||||||
|
if _tracker is None:
|
||||||
|
_tracker = load_team_tracker()
|
||||||
|
|
||||||
|
# 分离 Owner 和普通成员
|
||||||
|
all_accounts = _tracker.get("teams", {}).get(team_name, [])
|
||||||
|
owner_accounts = [acc for acc in all_accounts if acc.get("role") == "owner" and acc.get("status") != "completed"]
|
||||||
|
member_accounts = [acc for acc in all_accounts if acc.get("role") != "owner"]
|
||||||
|
|
||||||
|
# 统计完成数量 (只统计普通成员)
|
||||||
|
completed_count = sum(1 for acc in member_accounts if acc.get("status") == "completed")
|
||||||
|
member_count = len(member_accounts)
|
||||||
|
|
||||||
|
# 如果普通成员已完成目标数量,且没有未完成的 Owner,跳过
|
||||||
|
owner_incomplete = len(owner_accounts)
|
||||||
|
if member_count >= ACCOUNTS_PER_TEAM and completed_count == member_count and owner_incomplete == 0:
|
||||||
|
print_team_summary(team)
|
||||||
|
log.success(f"{team_name} 已完成 {completed_count}/{ACCOUNTS_PER_TEAM} 个成员账号,跳过")
|
||||||
|
return results, []
|
||||||
|
|
||||||
|
# 有未完成的才打印详细信息
|
||||||
|
log.header(f"开始处理 {team_name}")
|
||||||
|
|
||||||
|
# 打印 Team 当前状态
|
||||||
|
print_team_summary(team)
|
||||||
|
|
||||||
|
if completed_count > 0:
|
||||||
|
log.success(f"已完成 {completed_count} 个成员账号")
|
||||||
|
|
||||||
|
# ========== 检查可用席位 (用于邀请新成员) ==========
|
||||||
|
available_seats = check_available_seats(team)
|
||||||
|
log.info(f"Team 可用席位: {available_seats}")
|
||||||
|
|
||||||
|
# ========== 检查未完成的普通成员账号 ==========
|
||||||
|
incomplete_members = [acc for acc in member_accounts if acc.get("status") != "completed"]
|
||||||
|
|
||||||
|
invited_accounts = []
|
||||||
|
|
||||||
|
if incomplete_members:
|
||||||
|
# 有未完成的普通成员账号,优先处理
|
||||||
|
log.warning(f"发现 {len(incomplete_members)} 个未完成成员账号:")
|
||||||
|
for acc in incomplete_members:
|
||||||
|
log.step(f"{acc['email']} (状态: {acc.get('status', 'unknown')})")
|
||||||
|
|
||||||
|
invited_accounts = [{
|
||||||
|
"email": acc["email"],
|
||||||
|
"password": acc.get("password", DEFAULT_PASSWORD),
|
||||||
|
"status": acc.get("status", ""),
|
||||||
|
"role": acc.get("role", "member")
|
||||||
|
} for acc in incomplete_members]
|
||||||
|
log.info("继续处理未完成成员账号...", icon="start")
|
||||||
|
elif member_count >= ACCOUNTS_PER_TEAM:
|
||||||
|
# 普通成员已达到目标数量
|
||||||
|
log.success(f"已有 {member_count} 个成员账号,无需邀请新成员")
|
||||||
|
elif available_seats > 0:
|
||||||
|
# 需要邀请新成员
|
||||||
|
need_count = min(ACCOUNTS_PER_TEAM - member_count, available_seats)
|
||||||
|
|
||||||
|
if need_count > 0:
|
||||||
|
log.info(f"已有 {member_count} 个成员账号,可用席位 {available_seats},将创建 {need_count} 个")
|
||||||
|
|
||||||
|
# ========== 阶段 1: 批量创建邮箱 ==========
|
||||||
|
log.section(f"阶段 1: 批量创建 {need_count} 个邮箱")
|
||||||
|
|
||||||
|
with Timer("邮箱创建"):
|
||||||
|
accounts = batch_create_emails(need_count)
|
||||||
|
|
||||||
|
if len(accounts) > 0:
|
||||||
|
# ========== 阶段 2: 批量邀请到 Team ==========
|
||||||
|
log.section(f"阶段 2: 批量邀请 {len(accounts)} 个邮箱到 {team_name}")
|
||||||
|
|
||||||
|
emails = [acc["email"] for acc in accounts]
|
||||||
|
|
||||||
|
with Timer("批量邀请"):
|
||||||
|
invite_result = batch_invite_to_team(emails, team)
|
||||||
|
|
||||||
|
# 更新追踪记录 (带密码) - 立即保存
|
||||||
|
for acc in accounts:
|
||||||
|
if acc["email"] in invite_result.get("success", []):
|
||||||
|
add_account_with_password(_tracker, team_name, acc["email"], acc["password"], "invited")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
log.success("邀请记录已保存")
|
||||||
|
|
||||||
|
# 筛选成功邀请的账号
|
||||||
|
invited_accounts = [{
|
||||||
|
"email": acc["email"],
|
||||||
|
"password": acc["password"],
|
||||||
|
"status": "invited",
|
||||||
|
"role": "member"
|
||||||
|
} for acc in accounts if acc["email"] in invite_result.get("success", [])]
|
||||||
|
else:
|
||||||
|
log.warning(f"Team {team_name} 没有可用席位,无法邀请新成员")
|
||||||
|
|
||||||
|
# ========== 阶段 3: 处理普通成员 (注册 + Codex 授权 + CRS) ==========
|
||||||
|
if invited_accounts:
|
||||||
|
log.section(f"阶段 3: 逐个注册 OpenAI + Codex 授权 + CRS 入库")
|
||||||
|
member_results = process_accounts(invited_accounts, team_name)
|
||||||
|
results.extend(member_results)
|
||||||
|
|
||||||
|
# Owner 不在这里处理,统一放到所有 Team 处理完后
|
||||||
|
|
||||||
|
# ========== Team 处理完成 ==========
|
||||||
|
success_count = sum(1 for r in results if r["status"] == "success")
|
||||||
|
if results:
|
||||||
|
log.success(f"{team_name} 成员处理完成: {success_count}/{len(results)} 成功")
|
||||||
|
|
||||||
|
# 返回未完成的 Owner 列表供后续统一处理
|
||||||
|
return results, owner_accounts
|
||||||
|
|
||||||
|
|
||||||
|
def _get_team_by_name(team_name: str) -> dict:
|
||||||
|
"""根据名称获取 Team 配置"""
|
||||||
|
for team in TEAMS:
|
||||||
|
if team["name"] == team_name:
|
||||||
|
return team
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def process_accounts(accounts: list, team_name: str) -> list:
|
||||||
|
"""处理账号列表 (注册/授权/CRS)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accounts: 账号列表 [{"email", "password", "status", "role"}]
|
||||||
|
team_name: Team 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 处理结果
|
||||||
|
"""
|
||||||
|
global _tracker, _current_results, _shutdown_requested
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 启动进度跟踪 (Telegram Bot)
|
||||||
|
progress_start(team_name, len(accounts))
|
||||||
|
|
||||||
|
for i, account in enumerate(accounts):
|
||||||
|
if _shutdown_requested:
|
||||||
|
log.warning("检测到中断请求,停止处理...")
|
||||||
|
break
|
||||||
|
|
||||||
|
email = account["email"]
|
||||||
|
password = account["password"]
|
||||||
|
role = account.get("role", "member")
|
||||||
|
|
||||||
|
# 检查邮箱域名是否在黑名单中
|
||||||
|
if is_email_blacklisted(email):
|
||||||
|
domain = get_domain_from_email(email)
|
||||||
|
log.warning(f"邮箱域名 {domain} 在黑名单中,跳过: {email}")
|
||||||
|
|
||||||
|
# 从 tracker 中移除
|
||||||
|
remove_account_from_tracker(_tracker, team_name, email)
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
# 尝试创建新邮箱替代
|
||||||
|
if role != "owner":
|
||||||
|
log.info("尝试创建新邮箱替代...")
|
||||||
|
new_email, new_password = unified_create_email()
|
||||||
|
if new_email and not is_email_blacklisted(new_email):
|
||||||
|
# 邀请新邮箱
|
||||||
|
if invite_single_to_team(new_email, _get_team_by_name(team_name)):
|
||||||
|
add_account_with_password(_tracker, team_name, new_email, new_password, "invited")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
# 更新当前账号信息继续处理
|
||||||
|
email = new_email
|
||||||
|
password = new_password
|
||||||
|
account["email"] = email
|
||||||
|
account["password"] = password
|
||||||
|
log.success(f"已创建新邮箱替代: {email}")
|
||||||
|
else:
|
||||||
|
log.error("新邮箱邀请失败")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
log.error("无法创建有效的新邮箱")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.separator("#", 50)
|
||||||
|
log.info(f"处理账号 {i + 1}/{len(accounts)}: {email}", icon="account")
|
||||||
|
log.separator("#", 50)
|
||||||
|
|
||||||
|
# 更新进度: 当前账号
|
||||||
|
progress_update(account=email, step="Starting...")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"team": team_name,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"status": "failed",
|
||||||
|
"crs_id": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查账号状态,决定处理流程
|
||||||
|
account_status = account.get("status", "")
|
||||||
|
account_role = account.get("role", "member")
|
||||||
|
|
||||||
|
# 已完成的账号跳过
|
||||||
|
if account_status == "completed":
|
||||||
|
log.info(f"账号已完成,跳过: {email}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Team Owner 需要 OTP 登录 (仅限旧格式,状态为 team_owner)
|
||||||
|
is_team_owner_otp = account_status == "team_owner"
|
||||||
|
|
||||||
|
# 已授权但未入库的状态 (直接尝试入库,不重新授权)
|
||||||
|
# - authorized: 授权成功但入库失败
|
||||||
|
# - partial: 部分完成
|
||||||
|
need_crs_only = account_status in ["authorized", "partial"]
|
||||||
|
|
||||||
|
# 已注册但未授权的状态 (使用密码登录授权)
|
||||||
|
# - registered: 已注册,需要授权
|
||||||
|
# - auth_failed: 授权失败,重试
|
||||||
|
# - 新格式 Owner (role=owner 且状态不是 team_owner/completed) 也走密码登录
|
||||||
|
need_auth_only = (
|
||||||
|
account_status in ["registered", "auth_failed"]
|
||||||
|
or (account_role == "owner" and account_status not in ["team_owner", "completed", "authorized", "partial"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# 标记为处理中
|
||||||
|
update_account_status(_tracker, team_name, email, "processing")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
with Timer(f"账号 {email}"):
|
||||||
|
if is_team_owner_otp:
|
||||||
|
# 旧格式 Team Owner: 使用 OTP 登录授权
|
||||||
|
log.info("Team Owner 账号 (旧格式),使用一次性验证码登录...", icon="auth")
|
||||||
|
progress_update(step="OTP Login...")
|
||||||
|
auth_success, codex_data = login_and_authorize_with_otp(email)
|
||||||
|
register_success = auth_success
|
||||||
|
elif need_crs_only:
|
||||||
|
# 已授权但未入库: 跳过授权,直接尝试入库
|
||||||
|
log.info(f"已授权账号 (状态: {account_status}),跳过授权,直接入库...", icon="auth")
|
||||||
|
progress_update(step="Adding to CRS...")
|
||||||
|
register_success = True
|
||||||
|
codex_data = None # CPA/S2A 模式不需要 codex_data
|
||||||
|
# CRS 模式下,由于没有 codex_data,无法入库,需要重新授权
|
||||||
|
if AUTH_PROVIDER not in ("cpa", "s2a"):
|
||||||
|
log.warning("CRS 模式下已授权账号缺少 codex_data,需要重新授权")
|
||||||
|
auth_success, codex_data = authorize_only(email, password)
|
||||||
|
register_success = auth_success
|
||||||
|
elif need_auth_only:
|
||||||
|
# 已注册账号 (包括新格式 Owner): 使用密码登录授权
|
||||||
|
log.info(f"已注册账号 (状态: {account_status}, 角色: {account_role}),使用密码登录授权...", icon="auth")
|
||||||
|
progress_update(step="Authorizing...")
|
||||||
|
auth_success, codex_data = authorize_only(email, password)
|
||||||
|
register_success = True
|
||||||
|
else:
|
||||||
|
# 新账号: 注册 + Codex 授权
|
||||||
|
progress_update(step="Registering...")
|
||||||
|
register_success, codex_data = register_and_authorize(email, password)
|
||||||
|
|
||||||
|
# 检查是否是域名黑名单错误
|
||||||
|
if register_success == "domain_blacklisted":
|
||||||
|
domain = get_domain_from_email(email)
|
||||||
|
log.error(f"域名 {domain} 不被支持,加入黑名单")
|
||||||
|
add_domain_to_blacklist(domain)
|
||||||
|
|
||||||
|
# 从 tracker 中移除
|
||||||
|
remove_account_from_tracker(_tracker, team_name, email)
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
# 尝试创建新邮箱替代
|
||||||
|
log.info("尝试创建新邮箱替代...")
|
||||||
|
new_email, new_password = unified_create_email()
|
||||||
|
if new_email and not is_email_blacklisted(new_email):
|
||||||
|
# 邀请新邮箱
|
||||||
|
if invite_single_to_team(new_email, _get_team_by_name(team_name)):
|
||||||
|
add_account_with_password(_tracker, team_name, new_email, new_password, "invited")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
log.success(f"已创建新邮箱: {new_email},将在下次运行时处理")
|
||||||
|
else:
|
||||||
|
log.error("新邮箱邀请失败")
|
||||||
|
else:
|
||||||
|
log.error("无法创建有效的新邮箱")
|
||||||
|
|
||||||
|
continue # 跳过当前账号,继续下一个
|
||||||
|
|
||||||
|
if register_success and register_success != "domain_blacklisted":
|
||||||
|
update_account_status(_tracker, team_name, email, "registered")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
# CPA 模式: codex_data 为 None,授权成功后直接标记完成
|
||||||
|
# CRS 模式: 需要 codex_data,手动添加到 CRS
|
||||||
|
if AUTH_PROVIDER in ("cpa", "s2a"):
|
||||||
|
# CPA/S2A 模式: 授权成功即完成 (后台自动处理账号)
|
||||||
|
# codex_data 为 None 表示授权成功
|
||||||
|
update_account_status(_tracker, team_name, email, "authorized")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
result["status"] = "success"
|
||||||
|
result["crs_id"] = f"{AUTH_PROVIDER.upper()}-AUTO" # 标记为自动处理
|
||||||
|
|
||||||
|
update_account_status(_tracker, team_name, email, "completed")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
log.success(f"{AUTH_PROVIDER.upper()} 账号处理完成: {email}")
|
||||||
|
else:
|
||||||
|
# CRS 模式: 原有逻辑
|
||||||
|
if codex_data:
|
||||||
|
update_account_status(_tracker, team_name, email, "authorized")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
# 添加到 CRS
|
||||||
|
log.step("添加到 CRS...")
|
||||||
|
crs_result = crs_add_account(email, codex_data)
|
||||||
|
|
||||||
|
if crs_result:
|
||||||
|
crs_id = crs_result.get("id", "")
|
||||||
|
result["status"] = "success"
|
||||||
|
result["crs_id"] = crs_id
|
||||||
|
|
||||||
|
update_account_status(_tracker, team_name, email, "completed")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
log.success(f"账号处理完成: {email}")
|
||||||
|
else:
|
||||||
|
log.warning("CRS 入库失败,但注册和授权成功")
|
||||||
|
result["status"] = "partial"
|
||||||
|
update_account_status(_tracker, team_name, email, "partial")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
else:
|
||||||
|
log.warning("Codex 授权失败")
|
||||||
|
result["status"] = "auth_failed"
|
||||||
|
update_account_status(_tracker, team_name, email, "auth_failed")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
elif register_success != "domain_blacklisted":
|
||||||
|
if is_team_owner_otp:
|
||||||
|
log.error(f"OTP 登录授权失败: {email}")
|
||||||
|
else:
|
||||||
|
log.error(f"注册/授权失败: {email}")
|
||||||
|
update_account_status(_tracker, team_name, email, "register_failed")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
# 保存到 CSV
|
||||||
|
save_to_csv(
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
team_name=team_name,
|
||||||
|
status=result["status"],
|
||||||
|
crs_id=result.get("crs_id", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
_current_results.append(result)
|
||||||
|
|
||||||
|
# 更新进度: 账号完成
|
||||||
|
is_success = result["status"] in ("success", "completed")
|
||||||
|
progress_account_done(email, is_success)
|
||||||
|
|
||||||
|
# 账号之间的间隔
|
||||||
|
if i < len(accounts) - 1 and not _shutdown_requested:
|
||||||
|
wait_time = random.randint(3, 6)
|
||||||
|
log.info(f"等待 {wait_time}s 后处理下一个账号...", icon="wait")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_teams():
|
||||||
|
"""主函数: 遍历所有 Team"""
|
||||||
|
global _tracker, _current_results, _shutdown_requested
|
||||||
|
|
||||||
|
log.header("ChatGPT Team 批量注册自动化")
|
||||||
|
log.info(f"共 {len(TEAMS)} 个 Team 待处理", icon="team")
|
||||||
|
log.info(f"每个 Team 邀请 {ACCOUNTS_PER_TEAM} 个账号", icon="account")
|
||||||
|
log.info(f"统一密码: {DEFAULT_PASSWORD}", icon="code")
|
||||||
|
log.info("按 Ctrl+C 可安全退出并保存进度")
|
||||||
|
log.separator()
|
||||||
|
|
||||||
|
# 先显示整体状态
|
||||||
|
_tracker = load_team_tracker()
|
||||||
|
all_incomplete = get_all_incomplete_accounts(_tracker)
|
||||||
|
|
||||||
|
if all_incomplete:
|
||||||
|
total_incomplete = sum(len(accs) for accs in all_incomplete.values())
|
||||||
|
log.warning(f"发现 {total_incomplete} 个未完成账号,将优先处理")
|
||||||
|
|
||||||
|
_current_results = []
|
||||||
|
all_pending_owners = [] # 收集所有待处理的 Owner
|
||||||
|
|
||||||
|
with Timer("全部流程"):
|
||||||
|
# ========== 第一阶段: 处理所有 Team 的普通成员 ==========
|
||||||
|
for i, team in enumerate(TEAMS):
|
||||||
|
if _shutdown_requested:
|
||||||
|
log.warning("检测到中断请求,停止处理...")
|
||||||
|
break
|
||||||
|
|
||||||
|
log.separator("★", 60)
|
||||||
|
team_email = team.get('account') or team.get('owner_email', '')
|
||||||
|
log.highlight(f"Team {i + 1}/{len(TEAMS)}: {team['name']} ({team_email})", icon="team")
|
||||||
|
log.separator("★", 60)
|
||||||
|
|
||||||
|
results, pending_owners = process_single_team(team)
|
||||||
|
|
||||||
|
# 收集待处理的 Owner
|
||||||
|
if pending_owners:
|
||||||
|
for owner in pending_owners:
|
||||||
|
all_pending_owners.append({
|
||||||
|
"team_name": team["name"],
|
||||||
|
"email": owner["email"],
|
||||||
|
"password": owner.get("password", DEFAULT_PASSWORD),
|
||||||
|
"status": owner.get("status", "team_owner"),
|
||||||
|
"role": "owner"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Team 之间的间隔
|
||||||
|
if i < len(TEAMS) - 1 and not _shutdown_requested:
|
||||||
|
wait_time = 3
|
||||||
|
log.countdown(wait_time, "下一个 Team")
|
||||||
|
|
||||||
|
# ========== 第二阶段: 统一处理所有 Team Owner 的 CRS 授权 ==========
|
||||||
|
if all_pending_owners and not _shutdown_requested:
|
||||||
|
log.separator("★", 60)
|
||||||
|
log.header(f"统一处理 Team Owner CRS 授权 ({len(all_pending_owners)} 个)")
|
||||||
|
log.separator("★", 60)
|
||||||
|
|
||||||
|
for i, owner in enumerate(all_pending_owners):
|
||||||
|
if _shutdown_requested:
|
||||||
|
log.warning("检测到中断请求,停止处理...")
|
||||||
|
break
|
||||||
|
|
||||||
|
log.separator("#", 50)
|
||||||
|
log.info(f"Owner {i + 1}/{len(all_pending_owners)}: {owner['email']} ({owner['team_name']})", icon="account")
|
||||||
|
log.separator("#", 50)
|
||||||
|
|
||||||
|
owner_results = process_accounts([owner], owner["team_name"])
|
||||||
|
_current_results.extend(owner_results)
|
||||||
|
|
||||||
|
# Owner 之间的间隔
|
||||||
|
if i < len(all_pending_owners) - 1 and not _shutdown_requested:
|
||||||
|
wait_time = random.randint(5, 15)
|
||||||
|
log.info(f"等待 {wait_time}s 后处理下一个 Owner...", icon="wait")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
# 打印总结
|
||||||
|
print_summary(_current_results)
|
||||||
|
|
||||||
|
return _current_results
|
||||||
|
|
||||||
|
|
||||||
|
def run_single_team(team_index: int = 0):
|
||||||
|
"""只运行单个 Team (用于测试)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_index: Team 索引 (从 0 开始)
|
||||||
|
"""
|
||||||
|
global _current_results
|
||||||
|
|
||||||
|
if team_index >= len(TEAMS):
|
||||||
|
log.error(f"Team 索引超出范围 (0-{len(TEAMS) - 1})")
|
||||||
|
return
|
||||||
|
|
||||||
|
team = TEAMS[team_index]
|
||||||
|
log.info(f"单 Team 模式: {team['name']}", icon="start")
|
||||||
|
|
||||||
|
_current_results = []
|
||||||
|
results, pending_owners = process_single_team(team)
|
||||||
|
_current_results.extend(results)
|
||||||
|
|
||||||
|
# 单 Team 模式下也处理 Owner
|
||||||
|
if pending_owners:
|
||||||
|
log.section(f"处理 Team Owner ({len(pending_owners)} 个)")
|
||||||
|
for owner in pending_owners:
|
||||||
|
owner_data = {
|
||||||
|
"email": owner["email"],
|
||||||
|
"password": owner.get("password", DEFAULT_PASSWORD),
|
||||||
|
"status": owner.get("status", "team_owner"),
|
||||||
|
"role": "owner"
|
||||||
|
}
|
||||||
|
owner_results = process_accounts([owner_data], team["name"])
|
||||||
|
_current_results.extend(owner_results)
|
||||||
|
|
||||||
|
print_summary(_current_results)
|
||||||
|
|
||||||
|
return _current_results
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_only():
|
||||||
|
"""测试模式: 只创建邮箱和邀请,不注册"""
|
||||||
|
global _tracker
|
||||||
|
|
||||||
|
log.info("测试模式: 仅邮箱创建 + 邀请", icon="debug")
|
||||||
|
|
||||||
|
if len(TEAMS) == 0:
|
||||||
|
log.error("没有配置 Team")
|
||||||
|
return
|
||||||
|
|
||||||
|
team = TEAMS[0]
|
||||||
|
team_name = team["name"]
|
||||||
|
log.step(f"使用 Team: {team_name}")
|
||||||
|
|
||||||
|
# 创建邮箱
|
||||||
|
accounts = batch_create_emails(2) # 测试只创建 2 个
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
# 批量邀请
|
||||||
|
emails = [acc["email"] for acc in accounts]
|
||||||
|
result = batch_invite_to_team(emails, team)
|
||||||
|
|
||||||
|
# 保存到 tracker
|
||||||
|
_tracker = load_team_tracker()
|
||||||
|
for acc in accounts:
|
||||||
|
if acc["email"] in result.get("success", []):
|
||||||
|
add_account_with_password(_tracker, team_name, acc["email"], acc["password"], "invited")
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
log.success(f"测试完成: {len(result.get('success', []))} 个邀请成功")
|
||||||
|
log.info("记录已保存到 team_tracker.json", icon="save")
|
||||||
|
|
||||||
|
|
||||||
|
def show_status():
|
||||||
|
"""显示当前状态"""
|
||||||
|
log.header("当前状态")
|
||||||
|
|
||||||
|
tracker = load_team_tracker()
|
||||||
|
|
||||||
|
if not tracker.get("teams"):
|
||||||
|
log.info("没有任何记录")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_accounts = 0
|
||||||
|
total_completed = 0
|
||||||
|
total_incomplete = 0
|
||||||
|
|
||||||
|
for team_name, accounts in tracker["teams"].items():
|
||||||
|
log.info(f"{team_name}:", icon="team")
|
||||||
|
status_count = {}
|
||||||
|
for acc in accounts:
|
||||||
|
total_accounts += 1
|
||||||
|
status = acc.get("status", "unknown")
|
||||||
|
status_count[status] = status_count.get(status, 0) + 1
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
total_completed += 1
|
||||||
|
log.success(f"{acc['email']} ({status})")
|
||||||
|
elif status in ["invited", "registered", "authorized", "processing"]:
|
||||||
|
total_incomplete += 1
|
||||||
|
log.warning(f"{acc['email']} ({status})")
|
||||||
|
else:
|
||||||
|
total_incomplete += 1
|
||||||
|
log.error(f"{acc['email']} ({status})")
|
||||||
|
|
||||||
|
log.info(f"统计: {status_count}")
|
||||||
|
|
||||||
|
log.separator("-", 40)
|
||||||
|
log.info(f"总计: {total_accounts} 个账号")
|
||||||
|
log.success(f"完成: {total_completed}")
|
||||||
|
log.warning(f"未完成: {total_incomplete}")
|
||||||
|
log.info(f"最后更新: {tracker.get('last_updated', 'N/A')}", icon="time")
|
||||||
|
|
||||||
|
|
||||||
|
def process_team_with_login(team: dict, team_index: int, total: int):
|
||||||
|
"""处理单个 Team(包括获取 token、授权和后续流程)
|
||||||
|
|
||||||
|
用于格式3的 Team,登录时同时完成授权
|
||||||
|
"""
|
||||||
|
global _tracker
|
||||||
|
|
||||||
|
log.separator("★", 60)
|
||||||
|
log.highlight(f"Team {team_index + 1}/{total}: {team['name']} ({team['owner_email']})", icon="team")
|
||||||
|
log.separator("★", 60)
|
||||||
|
|
||||||
|
# 1. 登录并授权
|
||||||
|
log.info("登录并授权 Owner...", icon="auth")
|
||||||
|
proxy = get_next_proxy()
|
||||||
|
result = login_and_authorize_team_owner(
|
||||||
|
team["owner_email"],
|
||||||
|
team["owner_password"],
|
||||||
|
proxy
|
||||||
|
)
|
||||||
|
|
||||||
|
owner_result = None # Owner 的处理结果
|
||||||
|
|
||||||
|
if result.get("token"):
|
||||||
|
team["auth_token"] = result["token"]
|
||||||
|
if result.get("account_id"):
|
||||||
|
team["account_id"] = result["account_id"]
|
||||||
|
if result.get("authorized"):
|
||||||
|
team["authorized"] = True
|
||||||
|
|
||||||
|
# 立即保存
|
||||||
|
save_team_json()
|
||||||
|
|
||||||
|
if not result.get("token"):
|
||||||
|
log.error(f"登录失败,跳过此 Team")
|
||||||
|
return []
|
||||||
|
|
||||||
|
team["needs_login"] = False
|
||||||
|
|
||||||
|
if result.get("authorized"):
|
||||||
|
log.success(f"Owner 登录并授权成功")
|
||||||
|
# 记录 Owner 授权成功的结果
|
||||||
|
owner_result = {
|
||||||
|
"email": team["owner_email"],
|
||||||
|
"team": team["name"],
|
||||||
|
"status": "success",
|
||||||
|
"role": "owner"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
log.warning(f"Owner 登录成功但授权失败,后续可重试")
|
||||||
|
|
||||||
|
# 2. 添加 Owner 到 tracker (状态根据 authorized 决定)
|
||||||
|
_tracker = load_team_tracker()
|
||||||
|
add_team_owners_to_tracker(_tracker, DEFAULT_PASSWORD)
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
# 3. 处理该 Team 的成员
|
||||||
|
results, pending_owners = process_single_team(team)
|
||||||
|
|
||||||
|
# 4. 如果 Owner 授权失败,在这里重试
|
||||||
|
if pending_owners:
|
||||||
|
for owner in pending_owners:
|
||||||
|
# 只处理未授权的 Owner
|
||||||
|
if owner.get("status") != "authorized":
|
||||||
|
owner_data = {
|
||||||
|
"email": owner["email"],
|
||||||
|
"password": owner.get("password", DEFAULT_PASSWORD),
|
||||||
|
"status": owner.get("status", "registered"),
|
||||||
|
"role": "owner"
|
||||||
|
}
|
||||||
|
owner_results = process_accounts([owner_data], team["name"])
|
||||||
|
results.extend(owner_results)
|
||||||
|
|
||||||
|
# 添加 Owner 结果到返回列表
|
||||||
|
if owner_result:
|
||||||
|
results.insert(0, owner_result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ========== 启动前置检查 ==========
|
||||||
|
# 1. 根据配置选择验证对应的授权服务
|
||||||
|
if AUTH_PROVIDER == "cpa":
|
||||||
|
log.info("授权服务: CPA", icon="auth")
|
||||||
|
is_valid, message = cpa_verify_connection()
|
||||||
|
if is_valid:
|
||||||
|
log.success(f"CPA {message}")
|
||||||
|
else:
|
||||||
|
log.error(f"CPA 验证失败: {message}")
|
||||||
|
sys.exit(1)
|
||||||
|
elif AUTH_PROVIDER == "s2a":
|
||||||
|
log.info("授权服务: S2A (Sub2API)", icon="auth")
|
||||||
|
is_valid, message = s2a_verify_connection()
|
||||||
|
if is_valid:
|
||||||
|
log.success(f"S2A {message}")
|
||||||
|
else:
|
||||||
|
log.error(f"S2A 验证失败: {message}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
log.info("授权服务: CRS", icon="auth")
|
||||||
|
is_valid, message = crs_verify_token()
|
||||||
|
if is_valid:
|
||||||
|
log.success(f"CRS {message}")
|
||||||
|
else:
|
||||||
|
log.error(f"CRS Token 验证失败: {message}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 2. 分离需要登录和不需要登录的 Team
|
||||||
|
needs_login_teams = [t for t in TEAMS if t.get("format") == "new" and t.get("needs_login")]
|
||||||
|
ready_teams = [t for t in TEAMS if not (t.get("format") == "new" and t.get("needs_login"))]
|
||||||
|
|
||||||
|
# 3. 只对已有 token 的 Team 预加载 account_id 和添加到 tracker
|
||||||
|
if ready_teams:
|
||||||
|
success_count, fail_count = preload_all_account_ids()
|
||||||
|
_tracker = load_team_tracker()
|
||||||
|
add_team_owners_to_tracker(_tracker, DEFAULT_PASSWORD)
|
||||||
|
save_team_tracker(_tracker)
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
arg = sys.argv[1]
|
||||||
|
|
||||||
|
if arg == "test":
|
||||||
|
test_email_only()
|
||||||
|
elif arg == "single":
|
||||||
|
team_idx = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||||
|
run_single_team(team_idx)
|
||||||
|
elif arg == "status":
|
||||||
|
show_status()
|
||||||
|
else:
|
||||||
|
log.error(f"未知参数: {arg}")
|
||||||
|
log.info("用法: python run.py [test|single N|status]")
|
||||||
|
else:
|
||||||
|
# 默认运行
|
||||||
|
_current_results = []
|
||||||
|
|
||||||
|
# 先处理需要登录的 Team(获取 token 后立即处理)
|
||||||
|
if needs_login_teams:
|
||||||
|
log.separator("=", 60)
|
||||||
|
log.info(f"处理缺少 Token 的 Team ({len(needs_login_teams)} 个)")
|
||||||
|
log.separator("=", 60)
|
||||||
|
|
||||||
|
for i, team in enumerate(needs_login_teams):
|
||||||
|
if _shutdown_requested:
|
||||||
|
break
|
||||||
|
results = process_team_with_login(team, i, len(needs_login_teams))
|
||||||
|
_current_results.extend(results)
|
||||||
|
|
||||||
|
if i < len(needs_login_teams) - 1 and not _shutdown_requested:
|
||||||
|
wait_time = random.randint(3, 8)
|
||||||
|
log.info(f"等待 {wait_time}s...", icon="wait")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
# 再处理已有 token 的 Team
|
||||||
|
if ready_teams and not _shutdown_requested:
|
||||||
|
run_all_teams()
|
||||||
|
|
||||||
|
if _current_results:
|
||||||
|
print_summary(_current_results)
|
||||||
736
s2a_service.py
Normal file
736
s2a_service.py
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
# ==================== S2A (Sub2API) 服务模块 ====================
|
||||||
|
# 处理 Sub2API 系统相关功能 (OpenAI OAuth 授权、账号入库)
|
||||||
|
#
|
||||||
|
# S2A 与 CPA/CRS 的关键差异:
|
||||||
|
# - 认证方式: S2A 支持 Admin API Key (x-api-key) 或 JWT Token (Bearer)
|
||||||
|
# - 会话标识: S2A 使用 session_id
|
||||||
|
# - 授权流程: S2A 生成授权 URL -> 用户授权 -> 提交 code 换取 token -> 创建账号
|
||||||
|
# - 账号入库: S2A 可一步完成 (create-from-oauth) 或分步完成 (exchange + add_account)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from typing import Optional, Tuple, Dict, List, Any
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
S2A_API_BASE,
|
||||||
|
S2A_ADMIN_KEY,
|
||||||
|
S2A_ADMIN_TOKEN,
|
||||||
|
S2A_CONCURRENCY,
|
||||||
|
S2A_PRIORITY,
|
||||||
|
S2A_GROUP_IDS,
|
||||||
|
S2A_GROUP_NAMES,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
USER_AGENT,
|
||||||
|
)
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 分组 ID 缓存 ====================
|
||||||
|
_resolved_group_ids = None # 缓存解析后的 group_ids
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_with_retry() -> requests.Session:
|
||||||
|
"""创建带重试机制的 HTTP Session"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=5,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
http_session = create_session_with_retry()
|
||||||
|
|
||||||
|
|
||||||
|
def build_s2a_headers() -> Dict[str, str]:
|
||||||
|
"""构建 S2A API 请求的 Headers
|
||||||
|
|
||||||
|
优先使用 Admin API Key,如果未配置则使用 JWT Token
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"accept": "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": USER_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
if S2A_ADMIN_KEY:
|
||||||
|
headers["x-api-key"] = S2A_ADMIN_KEY
|
||||||
|
elif S2A_ADMIN_TOKEN:
|
||||||
|
headers["authorization"] = f"Bearer {S2A_ADMIN_TOKEN}"
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_method() -> Tuple[str, str]:
|
||||||
|
"""获取当前使用的认证方式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (method_name, credential_preview)
|
||||||
|
"""
|
||||||
|
if S2A_ADMIN_KEY:
|
||||||
|
preview = S2A_ADMIN_KEY[:16] + "..." if len(S2A_ADMIN_KEY) > 16 else S2A_ADMIN_KEY
|
||||||
|
return "Admin API Key", preview
|
||||||
|
elif S2A_ADMIN_TOKEN:
|
||||||
|
preview = S2A_ADMIN_TOKEN[:16] + "..." if len(S2A_ADMIN_TOKEN) > 16 else S2A_ADMIN_TOKEN
|
||||||
|
return "JWT Token", preview
|
||||||
|
return "None", ""
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 分组管理 ====================
|
||||||
|
def s2a_get_groups() -> List[Dict[str, Any]]:
|
||||||
|
"""获取所有分组列表"""
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(
|
||||||
|
f"{S2A_API_BASE}/admin/groups",
|
||||||
|
headers=headers,
|
||||||
|
params={"page": 1, "page_size": 100},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
data = result.get("data", {})
|
||||||
|
return data.get("items", [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"S2A 获取分组列表异常: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def s2a_resolve_group_ids(silent: bool = False) -> List[int]:
|
||||||
|
"""解析分组 ID 列表
|
||||||
|
|
||||||
|
优先使用 S2A_GROUP_IDS (直接配置的 ID)
|
||||||
|
如果未配置,则通过 S2A_GROUP_NAMES 查询 API 获取对应的 ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
silent: 是否静默模式 (不输出日志)
|
||||||
|
"""
|
||||||
|
global _resolved_group_ids
|
||||||
|
|
||||||
|
# 使用缓存
|
||||||
|
if _resolved_group_ids is not None:
|
||||||
|
return _resolved_group_ids
|
||||||
|
|
||||||
|
# 优先使用直接配置的 group_ids
|
||||||
|
if S2A_GROUP_IDS:
|
||||||
|
_resolved_group_ids = S2A_GROUP_IDS
|
||||||
|
return _resolved_group_ids
|
||||||
|
|
||||||
|
# 通过 group_names 查询获取 ID
|
||||||
|
if not S2A_GROUP_NAMES:
|
||||||
|
_resolved_group_ids = []
|
||||||
|
return _resolved_group_ids
|
||||||
|
|
||||||
|
groups = s2a_get_groups()
|
||||||
|
if not groups:
|
||||||
|
if not silent:
|
||||||
|
log.warning("S2A 无法获取分组列表,group_names 解析失败")
|
||||||
|
_resolved_group_ids = []
|
||||||
|
return _resolved_group_ids
|
||||||
|
|
||||||
|
# 构建 name -> id 映射
|
||||||
|
name_to_id = {g.get("name", "").lower(): g.get("id") for g in groups}
|
||||||
|
|
||||||
|
resolved = []
|
||||||
|
not_found = []
|
||||||
|
for name in S2A_GROUP_NAMES:
|
||||||
|
group_id = name_to_id.get(name.lower())
|
||||||
|
if group_id is not None:
|
||||||
|
resolved.append(group_id)
|
||||||
|
else:
|
||||||
|
not_found.append(name)
|
||||||
|
|
||||||
|
if not_found and not silent:
|
||||||
|
log.warning(f"S2A 分组未找到: {', '.join(not_found)}")
|
||||||
|
|
||||||
|
_resolved_group_ids = resolved
|
||||||
|
return _resolved_group_ids
|
||||||
|
|
||||||
|
|
||||||
|
def get_s2a_group_ids() -> List[int]:
|
||||||
|
"""获取当前配置的分组 ID 列表 (供外部调用)"""
|
||||||
|
return s2a_resolve_group_ids()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 连接验证 ====================
|
||||||
|
def s2a_verify_connection() -> Tuple[bool, str]:
|
||||||
|
"""验证 S2A 服务连接和认证有效性
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid, message)
|
||||||
|
"""
|
||||||
|
if not S2A_API_BASE:
|
||||||
|
return False, "S2A_API_BASE 未配置"
|
||||||
|
|
||||||
|
if not S2A_ADMIN_KEY and not S2A_ADMIN_TOKEN:
|
||||||
|
return False, "S2A_ADMIN_KEY 或 S2A_ADMIN_TOKEN 未配置"
|
||||||
|
|
||||||
|
auth_method, auth_preview = get_auth_method()
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 /admin/groups 接口验证连接 (支持 x-api-key 认证)
|
||||||
|
response = http_session.get(
|
||||||
|
f"{S2A_API_BASE}/admin/groups",
|
||||||
|
headers=headers,
|
||||||
|
params={"page": 1, "page_size": 1},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
# 解析分组配置
|
||||||
|
group_ids = s2a_resolve_group_ids(silent=True)
|
||||||
|
group_info = ""
|
||||||
|
if S2A_GROUP_NAMES:
|
||||||
|
group_info = f", 分组: {S2A_GROUP_NAMES} -> {group_ids}"
|
||||||
|
elif S2A_GROUP_IDS:
|
||||||
|
group_info = f", 分组 ID: {group_ids}"
|
||||||
|
|
||||||
|
return True, f"认证有效 (方式: {auth_method}{group_info})"
|
||||||
|
else:
|
||||||
|
return False, f"API 返回失败: {result.get('message', 'Unknown error')}"
|
||||||
|
|
||||||
|
elif response.status_code == 401:
|
||||||
|
return False, f"{auth_method} 无效或已过期 (HTTP 401)"
|
||||||
|
|
||||||
|
elif response.status_code == 403:
|
||||||
|
return False, f"{auth_method} 权限不足 (HTTP 403)"
|
||||||
|
|
||||||
|
else:
|
||||||
|
return False, f"服务异常 (HTTP {response.status_code})"
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return False, f"服务连接超时 ({S2A_API_BASE})"
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, f"无法连接到服务 ({S2A_API_BASE})"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"验证异常: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== OAuth 授权 ====================
|
||||||
|
def s2a_generate_auth_url(proxy_id: Optional[int] = None) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""生成 OpenAI OAuth 授权 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (auth_url, session_id) 或 (None, None)
|
||||||
|
"""
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
if proxy_id is not None:
|
||||||
|
payload["proxy_id"] = proxy_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{S2A_API_BASE}/admin/openai/generate-auth-url",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
data = result.get("data", {})
|
||||||
|
auth_url = data.get("auth_url")
|
||||||
|
session_id = data.get("session_id")
|
||||||
|
|
||||||
|
if auth_url and session_id:
|
||||||
|
log.success(f"生成 S2A 授权 URL 成功 (Session: {session_id[:16]}...)")
|
||||||
|
return auth_url, session_id
|
||||||
|
|
||||||
|
log.error(f"生成 S2A 授权 URL 失败: HTTP {response.status_code}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"S2A API 异常: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def s2a_create_account_from_oauth(
|
||||||
|
code: str,
|
||||||
|
session_id: str,
|
||||||
|
name: str = "",
|
||||||
|
proxy_id: Optional[int] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""一步完成:用授权码换取 token 并创建账号
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: 授权码
|
||||||
|
session_id: 会话 ID
|
||||||
|
name: 账号名称 (可选)
|
||||||
|
proxy_id: 代理 ID (可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 账号数据 或 None
|
||||||
|
"""
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
payload = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"code": code,
|
||||||
|
"concurrency": S2A_CONCURRENCY,
|
||||||
|
"priority": S2A_PRIORITY,
|
||||||
|
}
|
||||||
|
|
||||||
|
if name:
|
||||||
|
payload["name"] = name
|
||||||
|
if proxy_id is not None:
|
||||||
|
payload["proxy_id"] = proxy_id
|
||||||
|
|
||||||
|
group_ids = get_s2a_group_ids()
|
||||||
|
if group_ids:
|
||||||
|
payload["group_ids"] = group_ids
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{S2A_API_BASE}/admin/openai/create-from-oauth",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
account_data = result.get("data", {})
|
||||||
|
account_id = account_data.get("id")
|
||||||
|
account_name = account_data.get("name")
|
||||||
|
log.success(f"S2A 账号创建成功 (ID: {account_id}, Name: {account_name})")
|
||||||
|
return account_data
|
||||||
|
else:
|
||||||
|
log.error(f"S2A 账号创建失败: {result.get('message', 'Unknown error')}")
|
||||||
|
else:
|
||||||
|
log.error(f"S2A 账号创建失败: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"S2A 创建账号异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def s2a_add_account(
|
||||||
|
name: str,
|
||||||
|
token_info: Dict[str, Any],
|
||||||
|
proxy_id: Optional[int] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""将账号添加到 S2A 账号池
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 账号名称 (通常是邮箱)
|
||||||
|
token_info: Token 信息 (包含 access_token, refresh_token, expires_at)
|
||||||
|
proxy_id: 代理 ID (可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 账号数据 或 None
|
||||||
|
"""
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
|
||||||
|
credentials = {
|
||||||
|
"access_token": token_info.get("access_token"),
|
||||||
|
"refresh_token": token_info.get("refresh_token"),
|
||||||
|
"expires_at": token_info.get("expires_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if token_info.get("id_token"):
|
||||||
|
credentials["id_token"] = token_info.get("id_token")
|
||||||
|
if token_info.get("email"):
|
||||||
|
credentials["email"] = token_info.get("email")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": name,
|
||||||
|
"platform": "openai",
|
||||||
|
"type": "oauth",
|
||||||
|
"credentials": credentials,
|
||||||
|
"concurrency": S2A_CONCURRENCY,
|
||||||
|
"priority": S2A_PRIORITY,
|
||||||
|
"auto_pause_on_expired": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy_id is not None:
|
||||||
|
payload["proxy_id"] = proxy_id
|
||||||
|
|
||||||
|
group_ids = get_s2a_group_ids()
|
||||||
|
if group_ids:
|
||||||
|
payload["group_ids"] = group_ids
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{S2A_API_BASE}/admin/accounts",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
account_data = result.get("data", {})
|
||||||
|
account_id = account_data.get("id")
|
||||||
|
log.success(f"S2A 账号添加成功 (ID: {account_id}, Name: {name})")
|
||||||
|
return account_data
|
||||||
|
else:
|
||||||
|
log.error(f"S2A 添加账号失败: {result.get('message', 'Unknown error')}")
|
||||||
|
else:
|
||||||
|
log.error(f"S2A 添加账号失败: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"S2A 添加账号异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 账号管理 ====================
|
||||||
|
def s2a_get_accounts(platform: str = "openai") -> List[Dict[str, Any]]:
|
||||||
|
"""获取账号列表"""
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {"platform": platform} if platform else {}
|
||||||
|
response = http_session.get(
|
||||||
|
f"{S2A_API_BASE}/admin/accounts",
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
data = result.get("data", {})
|
||||||
|
if isinstance(data, dict) and "items" in data:
|
||||||
|
return data.get("items", [])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return data
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"S2A 获取账号列表异常: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def s2a_check_account_exists(email: str, platform: str = "openai") -> bool:
|
||||||
|
"""检查账号是否已存在"""
|
||||||
|
accounts = s2a_get_accounts(platform)
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
account_name = account.get("name", "").lower()
|
||||||
|
credentials = account.get("credentials", {})
|
||||||
|
account_email = credentials.get("email", "").lower()
|
||||||
|
|
||||||
|
if account_name == email.lower() or account_email == email.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 工具函数 ====================
|
||||||
|
def extract_code_from_url(url: str) -> Optional[str]:
|
||||||
|
"""从回调 URL 中提取授权码"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
return params.get("code", [None])[0]
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"解析 URL 失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_s2a_callback_url(url: str) -> bool:
|
||||||
|
"""检查 URL 是否为 S2A 回调 URL"""
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
return "localhost:1455/auth/callback" in url and "code=" in url
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 仪表盘统计 ====================
|
||||||
|
def s2a_get_dashboard_stats(timezone: str = "Asia/Shanghai") -> Optional[Dict[str, Any]]:
|
||||||
|
"""获取 S2A 仪表盘统计数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timezone: 时区 (默认 Asia/Shanghai)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 仪表盘数据 或 None
|
||||||
|
"""
|
||||||
|
if not S2A_API_BASE or (not S2A_ADMIN_KEY and not S2A_ADMIN_TOKEN):
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(
|
||||||
|
f"{S2A_API_BASE}/admin/dashboard/stats",
|
||||||
|
headers=headers,
|
||||||
|
params={"timezone": timezone},
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
return result.get("data", {})
|
||||||
|
else:
|
||||||
|
log.warning(f"S2A 仪表盘获取失败: {result.get('message', 'Unknown error')}")
|
||||||
|
else:
|
||||||
|
log.warning(f"S2A 仪表盘获取失败: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"S2A 仪表盘获取异常: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_dashboard_stats(stats: Dict[str, Any]) -> str:
|
||||||
|
"""格式化仪表盘统计数据为可读文本
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stats: 仪表盘原始数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 格式化后的文本
|
||||||
|
"""
|
||||||
|
if not stats:
|
||||||
|
return "No data available"
|
||||||
|
|
||||||
|
def fmt_num(n):
|
||||||
|
"""格式化数字 (添加千分位)"""
|
||||||
|
if isinstance(n, float):
|
||||||
|
return f"{n:,.2f}"
|
||||||
|
return f"{n:,}"
|
||||||
|
|
||||||
|
def fmt_tokens(n):
|
||||||
|
"""格式化 Token 数量 (简化显示)"""
|
||||||
|
if n >= 1_000_000_000:
|
||||||
|
return f"{n / 1_000_000_000:.2f}B"
|
||||||
|
elif n >= 1_000_000:
|
||||||
|
return f"{n / 1_000_000:.2f}M"
|
||||||
|
elif n >= 1_000:
|
||||||
|
return f"{n / 1_000:.1f}K"
|
||||||
|
return str(n)
|
||||||
|
|
||||||
|
# 账号状态
|
||||||
|
total_accounts = stats.get("total_accounts", 0)
|
||||||
|
normal_accounts = stats.get("normal_accounts", 0)
|
||||||
|
error_accounts = stats.get("error_accounts", 0)
|
||||||
|
ratelimit_accounts = stats.get("ratelimit_accounts", 0)
|
||||||
|
overload_accounts = stats.get("overload_accounts", 0)
|
||||||
|
|
||||||
|
# 今日统计
|
||||||
|
today_requests = stats.get("today_requests", 0)
|
||||||
|
today_tokens = stats.get("today_tokens", 0)
|
||||||
|
today_cost = stats.get("today_cost", 0)
|
||||||
|
today_input = stats.get("today_input_tokens", 0)
|
||||||
|
today_output = stats.get("today_output_tokens", 0)
|
||||||
|
today_cache_read = stats.get("today_cache_read_tokens", 0)
|
||||||
|
|
||||||
|
# 总计
|
||||||
|
total_requests = stats.get("total_requests", 0)
|
||||||
|
total_tokens = stats.get("total_tokens", 0)
|
||||||
|
total_cost = stats.get("total_cost", 0)
|
||||||
|
|
||||||
|
# 实时状态
|
||||||
|
rpm = stats.get("rpm", 0)
|
||||||
|
tpm = stats.get("tpm", 0)
|
||||||
|
active_users = stats.get("active_users", 0)
|
||||||
|
avg_duration = stats.get("average_duration_ms", 0)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"<b>S2A Dashboard</b>",
|
||||||
|
"",
|
||||||
|
"<b>Accounts</b>",
|
||||||
|
f" Total: {total_accounts} | Normal: {normal_accounts}",
|
||||||
|
f" Error: {error_accounts} | RateLimit: {ratelimit_accounts}",
|
||||||
|
"",
|
||||||
|
"<b>Today</b>",
|
||||||
|
f" Requests: {fmt_num(today_requests)}",
|
||||||
|
f" Tokens: {fmt_tokens(today_tokens)}",
|
||||||
|
f" Input: {fmt_tokens(today_input)} | Output: {fmt_tokens(today_output)}",
|
||||||
|
f" Cache: {fmt_tokens(today_cache_read)}",
|
||||||
|
f" Cost: ${fmt_num(today_cost)}",
|
||||||
|
"",
|
||||||
|
"<b>Total</b>",
|
||||||
|
f" Requests: {fmt_num(total_requests)}",
|
||||||
|
f" Tokens: {fmt_tokens(total_tokens)}",
|
||||||
|
f" Cost: ${fmt_num(total_cost)}",
|
||||||
|
"",
|
||||||
|
"<b>Realtime</b>",
|
||||||
|
f" RPM: {rpm} | TPM: {fmt_num(tpm)}",
|
||||||
|
f" Active Users: {active_users}",
|
||||||
|
f" Avg Duration: {avg_duration:.0f}ms",
|
||||||
|
]
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 批量导入账号 ====================
|
||||||
|
def s2a_import_account_with_token(
|
||||||
|
email: str,
|
||||||
|
access_token: str,
|
||||||
|
password: str = "",
|
||||||
|
proxy_id: Optional[int] = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""使用 access_token 直接导入账号到 S2A
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 账号邮箱
|
||||||
|
access_token: OpenAI access token (JWT)
|
||||||
|
password: 账号密码 (可选,用于备注)
|
||||||
|
proxy_id: 代理 ID (可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, message)
|
||||||
|
"""
|
||||||
|
if not S2A_API_BASE or (not S2A_ADMIN_KEY and not S2A_ADMIN_TOKEN):
|
||||||
|
return False, "S2A not configured"
|
||||||
|
|
||||||
|
headers = build_s2a_headers()
|
||||||
|
|
||||||
|
# 构建账号数据
|
||||||
|
credentials = {
|
||||||
|
"access_token": access_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": email,
|
||||||
|
"platform": "openai",
|
||||||
|
"type": "access_token",
|
||||||
|
"credentials": credentials,
|
||||||
|
"concurrency": S2A_CONCURRENCY,
|
||||||
|
"priority": S2A_PRIORITY,
|
||||||
|
"auto_pause_on_expired": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy_id is not None:
|
||||||
|
payload["proxy_id"] = proxy_id
|
||||||
|
|
||||||
|
group_ids = get_s2a_group_ids()
|
||||||
|
if group_ids:
|
||||||
|
payload["group_ids"] = group_ids
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(
|
||||||
|
f"{S2A_API_BASE}/admin/accounts",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
account_data = result.get("data", {})
|
||||||
|
account_id = account_data.get("id")
|
||||||
|
return True, f"ID: {account_id}"
|
||||||
|
else:
|
||||||
|
error_msg = result.get("message", "Unknown error")
|
||||||
|
# 检查是否已存在
|
||||||
|
if "exist" in error_msg.lower() or "duplicate" in error_msg.lower():
|
||||||
|
return False, "Already exists"
|
||||||
|
return False, error_msg
|
||||||
|
else:
|
||||||
|
return False, f"HTTP {response.status_code}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def s2a_batch_import_accounts(
|
||||||
|
accounts: List[Dict[str, str]],
|
||||||
|
progress_callback: Optional[callable] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""批量导入账号到 S2A
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accounts: 账号列表 [{"account": "email", "password": "pwd", "token": "jwt"}]
|
||||||
|
progress_callback: 进度回调函数 (current, total, email, status)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"success": int, "failed": int, "skipped": int, "results": [...]}
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
"success": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
|
||||||
|
total = len(accounts)
|
||||||
|
for i, acc in enumerate(accounts):
|
||||||
|
email = acc.get("account", "")
|
||||||
|
token = acc.get("token", "")
|
||||||
|
password = acc.get("password", "")
|
||||||
|
|
||||||
|
if not email or not token:
|
||||||
|
results["skipped"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"email": email or "unknown",
|
||||||
|
"status": "skipped",
|
||||||
|
"message": "Missing email or token"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
if s2a_check_account_exists(email):
|
||||||
|
results["skipped"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"email": email,
|
||||||
|
"status": "skipped",
|
||||||
|
"message": "Already exists"
|
||||||
|
})
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i + 1, total, email, "skipped")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 导入账号
|
||||||
|
success, message = s2a_import_account_with_token(email, token, password)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
results["success"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"email": email,
|
||||||
|
"status": "success",
|
||||||
|
"message": message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if "exist" in message.lower():
|
||||||
|
results["skipped"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"email": email,
|
||||||
|
"status": "skipped",
|
||||||
|
"message": message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results["failed"] += 1
|
||||||
|
results["details"].append({
|
||||||
|
"email": email,
|
||||||
|
"status": "failed",
|
||||||
|
"message": message
|
||||||
|
})
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
status = "success" if success else ("skipped" if "exist" in message.lower() else "failed")
|
||||||
|
progress_callback(i + 1, total, email, status)
|
||||||
|
|
||||||
|
return results
|
||||||
40
team.json.example
Normal file
40
team.json.example
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"_comment": "格式1 (旧格式): 通过 https://chatgpt.com/api/auth/session 获取授权信息",
|
||||||
|
"_comment2": "登录 ChatGPT Team 账号后访问此链接获取完整的 session 信息",
|
||||||
|
"user": {
|
||||||
|
"id": "user-xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
"email": "your-email@example.com",
|
||||||
|
"idp": "auth0",
|
||||||
|
"iat": 0,
|
||||||
|
"mfa": false
|
||||||
|
},
|
||||||
|
"expires": "2026-01-01T00:00:00.000Z",
|
||||||
|
"account": {
|
||||||
|
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"planType": "team",
|
||||||
|
"structure": "workspace",
|
||||||
|
"workspaceType": null,
|
||||||
|
"organizationId": "org-xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
"isDelinquent": false,
|
||||||
|
"gracePeriodId": null
|
||||||
|
},
|
||||||
|
"accessToken": "eyJhbGciOiJSUzI1NiIs...(your access token)",
|
||||||
|
"authProvider": "openai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_comment": "格式2 (新格式-有Token): 简化配置,只需邮箱、密码和Token",
|
||||||
|
"_comment2": "适用于已有 accessToken 的情况",
|
||||||
|
"account": "team-owner@example.com",
|
||||||
|
"password": "YourPassword@2025",
|
||||||
|
"token": "eyJhbGciOiJSUzI1NiIs...(your access token)",
|
||||||
|
"authorized": false,
|
||||||
|
"account_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_comment": "格式3 (新格式-无Token): 只需邮箱和密码,程序会自动登录获取Token",
|
||||||
|
"_comment2": "适用于没有 accessToken 的情况,程序启动时会自动登录",
|
||||||
|
"account": "team-owner2@example.com",
|
||||||
|
"password": "YourPassword@2025"
|
||||||
|
}
|
||||||
|
]
|
||||||
426
team_service.py
Normal file
426
team_service.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# ==================== Team 服务模块 ====================
|
||||||
|
# 处理 ChatGPT Team 邀请相关功能
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
TEAMS,
|
||||||
|
ACCOUNTS_PER_TEAM,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
USER_AGENT,
|
||||||
|
BROWSER_HEADLESS,
|
||||||
|
save_team_json
|
||||||
|
)
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_with_retry():
|
||||||
|
"""创建带重试机制的 HTTP Session"""
|
||||||
|
session = requests.Session()
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=5,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "POST", "OPTIONS"]
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
http_session = create_session_with_retry()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_account_id(team: dict, silent: bool = False) -> str:
|
||||||
|
"""通过 API 获取 account_id (用于新格式配置)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team: Team 配置
|
||||||
|
silent: 是否静默模式 (不输出日志)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: account_id
|
||||||
|
"""
|
||||||
|
if team.get("account_id"):
|
||||||
|
return team["account_id"]
|
||||||
|
|
||||||
|
auth_token = team.get("auth_token", "")
|
||||||
|
if not auth_token:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if not auth_token.startswith("Bearer "):
|
||||||
|
auth_token = f"Bearer {auth_token}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"accept": "*/*",
|
||||||
|
"authorization": auth_token,
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": USER_AGENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 accounts/check API 获取账户信息
|
||||||
|
response = http_session.get(
|
||||||
|
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27",
|
||||||
|
headers=headers,
|
||||||
|
timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
accounts = data.get("accounts", {})
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
# 优先查找 Team 账户 (plan_type 包含 team)
|
||||||
|
for acc_id, acc_info in accounts.items():
|
||||||
|
if acc_id == "default":
|
||||||
|
continue
|
||||||
|
account_data = acc_info.get("account", {})
|
||||||
|
plan_type = account_data.get("plan_type", "")
|
||||||
|
if "team" in plan_type.lower():
|
||||||
|
team["account_id"] = acc_id
|
||||||
|
if not silent:
|
||||||
|
log.success(f"获取到 Team account_id: {acc_id[:8]}...")
|
||||||
|
return acc_id
|
||||||
|
|
||||||
|
# 如果没有 Team 账户,取第一个非 default 的
|
||||||
|
for acc_id in accounts.keys():
|
||||||
|
if acc_id != "default":
|
||||||
|
team["account_id"] = acc_id
|
||||||
|
if not silent:
|
||||||
|
log.success(f"获取到 account_id: {acc_id[:8]}...")
|
||||||
|
return acc_id
|
||||||
|
else:
|
||||||
|
if not silent:
|
||||||
|
log.warning(f"获取 account_id 失败: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not silent:
|
||||||
|
log.warning(f"获取 account_id 失败: {e}")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def preload_all_account_ids() -> tuple[int, int]:
|
||||||
|
"""预加载所有 Team 的 account_id
|
||||||
|
|
||||||
|
在程序启动时调用,避免后续重复获取
|
||||||
|
只处理有 token 的 Team,没有 token 的跳过
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success_count, fail_count)
|
||||||
|
"""
|
||||||
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
# 只处理有 token 的 Team
|
||||||
|
teams_with_token = [t for t in TEAMS if t.get("auth_token")]
|
||||||
|
teams_need_fetch = [t for t in teams_with_token if not t.get("account_id")]
|
||||||
|
|
||||||
|
if not teams_need_fetch:
|
||||||
|
if teams_with_token:
|
||||||
|
log.success(f"所有 Team account_id 已缓存 ({len(teams_with_token)} 个)")
|
||||||
|
return len(teams_with_token), 0
|
||||||
|
|
||||||
|
log.info(f"预加载 {len(teams_need_fetch)} 个 Team 的 account_id...", icon="sync")
|
||||||
|
|
||||||
|
need_save = False
|
||||||
|
failed_teams = []
|
||||||
|
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
TextColumn("{task.fields[status]}"),
|
||||||
|
) as progress:
|
||||||
|
task = progress.add_task("加载中", total=len(teams_with_token), status="")
|
||||||
|
|
||||||
|
for team in teams_with_token:
|
||||||
|
progress.update(task, description=f"[cyan]{team['name']}", status="")
|
||||||
|
|
||||||
|
if team.get("account_id"):
|
||||||
|
success_count += 1
|
||||||
|
progress.update(task, advance=1, status="[green]✓ 已缓存")
|
||||||
|
continue
|
||||||
|
|
||||||
|
account_id = fetch_account_id(team, silent=True)
|
||||||
|
if account_id:
|
||||||
|
success_count += 1
|
||||||
|
progress.update(task, advance=1, status="[green]✓")
|
||||||
|
if team.get("format") == "new":
|
||||||
|
need_save = True
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
failed_teams.append(team['name'])
|
||||||
|
progress.update(task, advance=1, status="[red]✗")
|
||||||
|
|
||||||
|
# 输出失败的 team
|
||||||
|
for name in failed_teams:
|
||||||
|
log.warning(f"Team {name}: 获取 account_id 失败")
|
||||||
|
|
||||||
|
# 持久化到 team.json
|
||||||
|
if need_save:
|
||||||
|
if save_team_json():
|
||||||
|
log.success(f"account_id 已保存到 team.json")
|
||||||
|
|
||||||
|
if fail_count == 0 and success_count > 0:
|
||||||
|
log.success(f"所有 Team account_id 加载完成 ({success_count} 个)")
|
||||||
|
elif fail_count > 0:
|
||||||
|
log.warning(f"account_id 加载: 成功 {success_count}, 失败 {fail_count}")
|
||||||
|
|
||||||
|
return success_count, fail_count
|
||||||
|
|
||||||
|
|
||||||
|
def build_invite_headers(team: dict) -> dict:
|
||||||
|
"""构建邀请请求的 Headers"""
|
||||||
|
auth_token = team["auth_token"]
|
||||||
|
if not auth_token.startswith("Bearer "):
|
||||||
|
auth_token = f"Bearer {auth_token}"
|
||||||
|
|
||||||
|
# 如果没有 account_id,尝试获取
|
||||||
|
account_id = team.get("account_id", "")
|
||||||
|
if not account_id:
|
||||||
|
account_id = fetch_account_id(team)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-language": "zh-CN,zh;q=0.9",
|
||||||
|
"authorization": auth_token,
|
||||||
|
"content-type": "application/json",
|
||||||
|
"origin": "https://chatgpt.com",
|
||||||
|
"referer": "https://chatgpt.com/",
|
||||||
|
"user-agent": USER_AGENT,
|
||||||
|
"sec-ch-ua": '"Chromium";v="135", "Not)A;Brand";v="99", "Google Chrome";v="135"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
|
}
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
headers["chatgpt-account-id"] = account_id
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def invite_single_email(email: str, team: dict) -> tuple[bool, str]:
|
||||||
|
"""邀请单个邮箱到 Team
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, message)
|
||||||
|
"""
|
||||||
|
headers = build_invite_headers(team)
|
||||||
|
payload = {
|
||||||
|
"email_addresses": [email],
|
||||||
|
"role": "standard-user",
|
||||||
|
"resend_emails": True
|
||||||
|
}
|
||||||
|
invite_url = f"https://chatgpt.com/backend-api/accounts/{team['account_id']}/invites"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(invite_url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get("account_invites"):
|
||||||
|
return True, "邀请成功"
|
||||||
|
elif result.get("errored_emails"):
|
||||||
|
return False, f"邀请错误: {result['errored_emails']}"
|
||||||
|
else:
|
||||||
|
return True, "邀请已发送"
|
||||||
|
else:
|
||||||
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_invite_to_team(emails: list, team: dict) -> dict:
|
||||||
|
"""批量邀请多个邮箱到 Team
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emails: 邮箱列表
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"success": [...], "failed": [...]}
|
||||||
|
"""
|
||||||
|
log.info(f"批量邀请 {len(emails)} 个邮箱到 {team['name']} (ID: {team['account_id'][:8]}...)", icon="email")
|
||||||
|
|
||||||
|
headers = build_invite_headers(team)
|
||||||
|
payload = {
|
||||||
|
"email_addresses": emails,
|
||||||
|
"role": "standard-user",
|
||||||
|
"resend_emails": True
|
||||||
|
}
|
||||||
|
invite_url = f"https://chatgpt.com/backend-api/accounts/{team['account_id']}/invites"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": [],
|
||||||
|
"failed": []
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.post(invite_url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
resp_data = response.json()
|
||||||
|
|
||||||
|
# 处理成功邀请
|
||||||
|
if resp_data.get("account_invites"):
|
||||||
|
for invite in resp_data["account_invites"]:
|
||||||
|
invited_email = invite.get("email_address", "")
|
||||||
|
if invited_email:
|
||||||
|
result["success"].append(invited_email)
|
||||||
|
log.success(f"邀请成功: {invited_email}")
|
||||||
|
|
||||||
|
# 处理失败的邮箱
|
||||||
|
if resp_data.get("errored_emails"):
|
||||||
|
for err in resp_data["errored_emails"]:
|
||||||
|
err_email = err.get("email", "")
|
||||||
|
err_msg = err.get("error", "Unknown error")
|
||||||
|
if err_email:
|
||||||
|
result["failed"].append({"email": err_email, "error": err_msg})
|
||||||
|
log.error(f"邀请失败: {err_email} - {err_msg}")
|
||||||
|
|
||||||
|
# 如果没有明确的成功/失败信息,假设全部成功
|
||||||
|
if not resp_data.get("account_invites") and not resp_data.get("errored_emails"):
|
||||||
|
result["success"] = emails
|
||||||
|
for email in emails:
|
||||||
|
log.success(f"邀请成功: {email}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.error(f"批量邀请失败: HTTP {response.status_code}")
|
||||||
|
result["failed"] = [{"email": e, "error": f"HTTP {response.status_code}"} for e in emails]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"批量邀请异常: {e}")
|
||||||
|
result["failed"] = [{"email": e, "error": str(e)} for e in emails]
|
||||||
|
|
||||||
|
log.info(f"邀请结果: 成功 {len(result['success'])}, 失败 {len(result['failed'])}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def invite_single_to_team(email: str, team: dict) -> bool:
|
||||||
|
"""邀请单个邮箱到 Team
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功
|
||||||
|
"""
|
||||||
|
result = batch_invite_to_team([email], team)
|
||||||
|
return email in result.get("success", [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_stats(team: dict) -> dict:
|
||||||
|
"""获取 Team 的统计信息 (席位使用情况)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"seats_in_use": int, "seats_entitled": int, "pending_invites": int}
|
||||||
|
"""
|
||||||
|
headers = build_invite_headers(team)
|
||||||
|
|
||||||
|
# 获取订阅信息
|
||||||
|
subs_url = f"https://chatgpt.com/backend-api/subscriptions?account_id={team['account_id']}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(subs_url, headers=headers, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
"seats_in_use": data.get("seats_in_use", 0),
|
||||||
|
"seats_entitled": data.get("seats_entitled", 0),
|
||||||
|
"pending_invites": data.get("pending_invites", 0),
|
||||||
|
"plan_type": data.get("plan_type", ""),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
log.warning(f"获取 Team 统计失败: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"获取 Team 统计异常: {e}")
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_invites(team: dict) -> list:
|
||||||
|
"""获取 Team 的待处理邀请列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 待处理邀请列表
|
||||||
|
"""
|
||||||
|
headers = build_invite_headers(team)
|
||||||
|
url = f"https://chatgpt.com/backend-api/accounts/{team['account_id']}/invites?offset=0&limit=100&query="
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_session.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("items", [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"获取待处理邀请异常: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_available_seats(team: dict) -> int:
|
||||||
|
"""检查 Team 可用席位数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team: Team 配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 可用席位数
|
||||||
|
"""
|
||||||
|
stats = get_team_stats(team)
|
||||||
|
|
||||||
|
if not stats:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
seats_in_use = stats.get("seats_in_use", 0)
|
||||||
|
seats_entitled = stats.get("seats_entitled", 5) # 默认 5 席位
|
||||||
|
pending_invites = stats.get("pending_invites", 0) # 待处理邀请数
|
||||||
|
|
||||||
|
# 可用席位 = 总席位 - 已使用席位 - 待处理邀请 (待处理邀请也算预占用)
|
||||||
|
available = seats_entitled - seats_in_use - pending_invites
|
||||||
|
return max(0, available)
|
||||||
|
|
||||||
|
|
||||||
|
def print_team_summary(team: dict):
|
||||||
|
"""打印 Team 摘要信息"""
|
||||||
|
stats = get_team_stats(team)
|
||||||
|
pending = get_pending_invites(team)
|
||||||
|
|
||||||
|
log.info(f"{team['name']} 状态 (ID: {team['account_id'][:8]}...)", icon="team")
|
||||||
|
|
||||||
|
if stats:
|
||||||
|
seats_in_use = stats.get('seats_in_use', 0)
|
||||||
|
seats_entitled = stats.get('seats_entitled', 5)
|
||||||
|
pending_count = stats.get('pending_invites', 0)
|
||||||
|
# 可用席位 = 总席位 - 已使用 - 待处理邀请
|
||||||
|
available = seats_entitled - seats_in_use - pending_count
|
||||||
|
seats_info = f"席位: {seats_in_use}/{seats_entitled}"
|
||||||
|
pending_info = f"待处理邀请: {pending_count}"
|
||||||
|
available_info = f"可用席位: {max(0, available)}"
|
||||||
|
log.info(f"{seats_info} | {pending_info} | {available_info}")
|
||||||
|
else:
|
||||||
|
log.warning("无法获取状态信息")
|
||||||
652
telegram_bot.py
Normal file
652
telegram_bot.py
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
# ==================== Telegram Bot 主程序 ====================
|
||||||
|
# 通过 Telegram 远程控制 OpenAI Team 批量注册任务
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from telegram import Update, Bot
|
||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
CommandHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
ContextTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
TELEGRAM_BOT_TOKEN,
|
||||||
|
TELEGRAM_ADMIN_CHAT_IDS,
|
||||||
|
TELEGRAM_ENABLED,
|
||||||
|
TEAMS,
|
||||||
|
AUTH_PROVIDER,
|
||||||
|
TEAM_JSON_FILE,
|
||||||
|
TELEGRAM_CHECK_INTERVAL,
|
||||||
|
TELEGRAM_LOW_STOCK_THRESHOLD,
|
||||||
|
)
|
||||||
|
from utils import load_team_tracker
|
||||||
|
from bot_notifier import BotNotifier, set_notifier, progress_finish
|
||||||
|
from s2a_service import s2a_get_dashboard_stats, format_dashboard_stats
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
def admin_only(func):
|
||||||
|
"""装饰器: 仅允许管理员执行命令"""
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
if user_id not in TELEGRAM_ADMIN_CHAT_IDS:
|
||||||
|
await update.message.reply_text("Unauthorized. Your ID is not in admin list.")
|
||||||
|
return
|
||||||
|
return await func(self, update, context)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionerBot:
|
||||||
|
"""OpenAI Team Provisioner Telegram Bot"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
self.current_task: Optional[asyncio.Task] = None
|
||||||
|
self.current_team: Optional[str] = None
|
||||||
|
self.app: Optional[Application] = None
|
||||||
|
self.notifier: Optional[BotNotifier] = None
|
||||||
|
self._shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""启动 Bot"""
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
log.error("Telegram Bot Token not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建 Application
|
||||||
|
self.app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# 初始化通知器
|
||||||
|
self.notifier = BotNotifier(self.app.bot, TELEGRAM_ADMIN_CHAT_IDS)
|
||||||
|
set_notifier(self.notifier)
|
||||||
|
|
||||||
|
# 注册命令处理器
|
||||||
|
handlers = [
|
||||||
|
("start", self.cmd_help),
|
||||||
|
("help", self.cmd_help),
|
||||||
|
("status", self.cmd_status),
|
||||||
|
("team", self.cmd_team),
|
||||||
|
("run", self.cmd_run),
|
||||||
|
("run_all", self.cmd_run_all),
|
||||||
|
("stop", self.cmd_stop),
|
||||||
|
("logs", self.cmd_logs),
|
||||||
|
("dashboard", self.cmd_dashboard),
|
||||||
|
("import", self.cmd_import),
|
||||||
|
("stock", self.cmd_stock),
|
||||||
|
]
|
||||||
|
for cmd, handler in handlers:
|
||||||
|
self.app.add_handler(CommandHandler(cmd, handler))
|
||||||
|
|
||||||
|
# 注册文件上传处理器 (JSON 文件)
|
||||||
|
self.app.add_handler(MessageHandler(
|
||||||
|
filters.Document.MimeType("application/json"),
|
||||||
|
self.handle_json_file
|
||||||
|
))
|
||||||
|
|
||||||
|
# 注册定时检查任务
|
||||||
|
if TELEGRAM_CHECK_INTERVAL > 0 and AUTH_PROVIDER == "s2a":
|
||||||
|
self.app.job_queue.run_repeating(
|
||||||
|
self.scheduled_stock_check,
|
||||||
|
interval=TELEGRAM_CHECK_INTERVAL,
|
||||||
|
first=60, # 启动后1分钟执行第一次
|
||||||
|
name="stock_check"
|
||||||
|
)
|
||||||
|
log.info(f"Stock check scheduled every {TELEGRAM_CHECK_INTERVAL}s")
|
||||||
|
|
||||||
|
# 启动通知器
|
||||||
|
await self.notifier.start()
|
||||||
|
|
||||||
|
log.success("Telegram Bot started")
|
||||||
|
log.info(f"Admin Chat IDs: {TELEGRAM_ADMIN_CHAT_IDS}")
|
||||||
|
|
||||||
|
# 发送启动通知
|
||||||
|
await self.notifier.notify("<b>Bot Started</b>\nReady for commands. Send /help for usage.")
|
||||||
|
|
||||||
|
# 运行 Bot
|
||||||
|
await self.app.initialize()
|
||||||
|
await self.app.start()
|
||||||
|
await self.app.updater.start_polling(drop_pending_updates=True)
|
||||||
|
|
||||||
|
# 等待关闭信号
|
||||||
|
await self._shutdown_event.wait()
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
await self.app.updater.stop()
|
||||||
|
await self.app.stop()
|
||||||
|
await self.app.shutdown()
|
||||||
|
await self.notifier.stop()
|
||||||
|
|
||||||
|
def request_shutdown(self):
|
||||||
|
"""请求关闭 Bot"""
|
||||||
|
self._shutdown_event.set()
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""显示帮助信息"""
|
||||||
|
help_text = """<b>OpenAI Team Provisioner Bot</b>
|
||||||
|
|
||||||
|
<b>Commands:</b>
|
||||||
|
/status - View all teams status
|
||||||
|
/team <n> - View team N details
|
||||||
|
/run <n> - Start processing team N
|
||||||
|
/run_all - Start processing all teams
|
||||||
|
/stop - Stop current task
|
||||||
|
/logs [n] - View recent n logs (default 10)
|
||||||
|
/dashboard - View S2A dashboard stats
|
||||||
|
/stock - Check account stock
|
||||||
|
/import - Upload accounts to team.json
|
||||||
|
/help - Show this help
|
||||||
|
|
||||||
|
<b>Upload Accounts:</b>
|
||||||
|
Send a JSON file or use /import with JSON data:
|
||||||
|
<code>[{"account":"email","password":"pwd","token":"jwt"},...]</code>
|
||||||
|
Then use /run to process them.
|
||||||
|
|
||||||
|
<b>Examples:</b>
|
||||||
|
<code>/run 0</code> - Process first team
|
||||||
|
<code>/team 1</code> - View second team status
|
||||||
|
<code>/logs 20</code> - View last 20 logs"""
|
||||||
|
await update.message.reply_text(help_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""查看所有 Team 状态"""
|
||||||
|
tracker = load_team_tracker()
|
||||||
|
teams_data = tracker.get("teams", {})
|
||||||
|
|
||||||
|
if not teams_data:
|
||||||
|
await update.message.reply_text("No data yet. Run tasks first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ["<b>Teams Status</b>\n"]
|
||||||
|
for team_name, accounts in teams_data.items():
|
||||||
|
total = len(accounts)
|
||||||
|
completed = sum(1 for a in accounts if a.get("status") == "completed")
|
||||||
|
failed = sum(1 for a in accounts if "fail" in a.get("status", "").lower())
|
||||||
|
pending = total - completed - failed
|
||||||
|
|
||||||
|
status_icon = "OK" if completed == total else ("FAIL" if failed > 0 else "...")
|
||||||
|
lines.append(
|
||||||
|
f"[{status_icon}] <b>{team_name}</b>: {completed}/{total} "
|
||||||
|
f"(F:{failed} P:{pending})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 当前任务状态
|
||||||
|
if self.current_task and not self.current_task.done():
|
||||||
|
lines.append(f"\n<b>Running:</b> {self.current_team or 'Unknown'}")
|
||||||
|
|
||||||
|
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_team(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""查看指定 Team 详情"""
|
||||||
|
if not context.args:
|
||||||
|
await update.message.reply_text("Usage: /team <index>\nExample: /team 0")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
team_idx = int(context.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("Invalid team index. Must be a number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if team_idx < 0 or team_idx >= len(TEAMS):
|
||||||
|
await update.message.reply_text(f"Team index out of range. Valid: 0-{len(TEAMS)-1}")
|
||||||
|
return
|
||||||
|
|
||||||
|
team = TEAMS[team_idx]
|
||||||
|
team_name = team.get("name", f"Team{team_idx}")
|
||||||
|
|
||||||
|
tracker = load_team_tracker()
|
||||||
|
accounts = tracker.get("teams", {}).get(team_name, [])
|
||||||
|
|
||||||
|
lines = [f"<b>Team {team_idx}: {team_name}</b>\n"]
|
||||||
|
lines.append(f"Owner: {team.get('owner_email', 'N/A')}")
|
||||||
|
lines.append(f"Accounts: {len(accounts)}\n")
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
for acc in accounts:
|
||||||
|
email = acc.get("email", "")
|
||||||
|
status = acc.get("status", "unknown")
|
||||||
|
role = acc.get("role", "member")
|
||||||
|
icon = {"completed": "OK", "authorized": "AUTH", "registered": "REG"}.get(
|
||||||
|
status, "FAIL" if "fail" in status.lower() else "..."
|
||||||
|
)
|
||||||
|
role_tag = " [O]" if role == "owner" else ""
|
||||||
|
lines.append(f"[{icon}] {email}{role_tag}")
|
||||||
|
else:
|
||||||
|
lines.append("No accounts processed yet.")
|
||||||
|
|
||||||
|
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_run(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""启动处理指定 Team"""
|
||||||
|
if self.current_task and not self.current_task.done():
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Task already running: {self.current_team}\nUse /stop to cancel."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not context.args:
|
||||||
|
await update.message.reply_text("Usage: /run <team_index>\nExample: /run 0")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
team_idx = int(context.args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("Invalid team index. Must be a number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if team_idx < 0 or team_idx >= len(TEAMS):
|
||||||
|
await update.message.reply_text(f"Team index out of range. Valid: 0-{len(TEAMS)-1}")
|
||||||
|
return
|
||||||
|
|
||||||
|
team_name = TEAMS[team_idx].get("name", f"Team{team_idx}")
|
||||||
|
self.current_team = team_name
|
||||||
|
|
||||||
|
await update.message.reply_text(f"Starting task for Team {team_idx}: {team_name}...")
|
||||||
|
|
||||||
|
# 在后台线程执行任务
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self.current_task = loop.run_in_executor(
|
||||||
|
self.executor,
|
||||||
|
self._run_team_task,
|
||||||
|
team_idx
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加完成回调
|
||||||
|
self.current_task = asyncio.ensure_future(self._wrap_task(self.current_task, team_name))
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_run_all(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""启动处理所有 Team"""
|
||||||
|
if self.current_task and not self.current_task.done():
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Task already running: {self.current_team}\nUse /stop to cancel."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_team = "ALL"
|
||||||
|
await update.message.reply_text(f"Starting task for ALL teams ({len(TEAMS)} teams)...")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self.current_task = loop.run_in_executor(
|
||||||
|
self.executor,
|
||||||
|
self._run_all_teams_task
|
||||||
|
)
|
||||||
|
|
||||||
|
self.current_task = asyncio.ensure_future(self._wrap_task(self.current_task, "ALL"))
|
||||||
|
|
||||||
|
async def _wrap_task(self, task, team_name: str):
|
||||||
|
"""包装任务以处理完成通知"""
|
||||||
|
try:
|
||||||
|
result = await task
|
||||||
|
success = sum(1 for r in (result or []) if r.get("status") == "completed")
|
||||||
|
failed = len(result or []) - success
|
||||||
|
await self.notifier.notify_task_completed(team_name, success, failed)
|
||||||
|
except Exception as e:
|
||||||
|
await self.notifier.notify_error(f"Task failed: {team_name}", str(e))
|
||||||
|
finally:
|
||||||
|
self.current_team = None
|
||||||
|
# 清理进度跟踪
|
||||||
|
progress_finish()
|
||||||
|
|
||||||
|
def _run_team_task(self, team_idx: int):
|
||||||
|
"""执行单个 Team 任务 (在线程池中运行)"""
|
||||||
|
# 延迟导入避免循环依赖
|
||||||
|
from run import run_single_team
|
||||||
|
return run_single_team(team_idx)
|
||||||
|
|
||||||
|
def _run_all_teams_task(self):
|
||||||
|
"""执行所有 Team 任务 (在线程池中运行)"""
|
||||||
|
from run import run_all_teams
|
||||||
|
return run_all_teams()
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_stop(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""停止当前任务"""
|
||||||
|
if not self.current_task or self.current_task.done():
|
||||||
|
await update.message.reply_text("No task is running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 注意: 由于任务在线程池中运行,无法直接取消
|
||||||
|
# 这里只能发送信号
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Requesting stop for: {self.current_team}\n"
|
||||||
|
"Note: Current account processing will complete before stopping."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置全局停止标志
|
||||||
|
try:
|
||||||
|
import run
|
||||||
|
run._shutdown_requested = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_logs(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""查看最近日志"""
|
||||||
|
try:
|
||||||
|
n = int(context.args[0]) if context.args else 10
|
||||||
|
except ValueError:
|
||||||
|
n = 10
|
||||||
|
|
||||||
|
n = min(n, 50) # 限制最大条数
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config import BASE_DIR
|
||||||
|
log_file = BASE_DIR / "logs" / "app.log"
|
||||||
|
if not log_file.exists():
|
||||||
|
await update.message.reply_text("No log file found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
recent = lines[-n:] if len(lines) >= n else lines
|
||||||
|
|
||||||
|
if not recent:
|
||||||
|
await update.message.reply_text("Log file is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 格式化日志 (移除 ANSI 颜色码)
|
||||||
|
import re
|
||||||
|
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
||||||
|
clean_lines = [ansi_escape.sub('', line.strip()) for line in recent]
|
||||||
|
|
||||||
|
log_text = "\n".join(clean_lines)
|
||||||
|
if len(log_text) > 4000:
|
||||||
|
log_text = log_text[-4000:]
|
||||||
|
|
||||||
|
await update.message.reply_text(f"<code>{log_text}</code>", parse_mode="HTML")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Error reading logs: {e}")
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_dashboard(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""查看 S2A 仪表盘统计"""
|
||||||
|
if AUTH_PROVIDER != "s2a":
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Dashboard only available for S2A provider.\n"
|
||||||
|
f"Current provider: {AUTH_PROVIDER}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text("Fetching dashboard stats...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = s2a_get_dashboard_stats()
|
||||||
|
if stats:
|
||||||
|
text = format_dashboard_stats(stats)
|
||||||
|
await update.message.reply_text(text, parse_mode="HTML")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Failed to fetch dashboard stats.\n"
|
||||||
|
"Check S2A configuration and API connection."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Error: {e}")
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_stock(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""查看账号存货"""
|
||||||
|
if AUTH_PROVIDER != "s2a":
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Stock check only available for S2A provider.\n"
|
||||||
|
f"Current provider: {AUTH_PROVIDER}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = s2a_get_dashboard_stats()
|
||||||
|
if not stats:
|
||||||
|
await update.message.reply_text("Failed to fetch stock info.")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = self._format_stock_message(stats)
|
||||||
|
await update.message.reply_text(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
async def scheduled_stock_check(self, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""定时检查账号存货"""
|
||||||
|
try:
|
||||||
|
stats = s2a_get_dashboard_stats()
|
||||||
|
if not stats:
|
||||||
|
return
|
||||||
|
|
||||||
|
normal = stats.get("normal_accounts", 0)
|
||||||
|
total = stats.get("total_accounts", 0)
|
||||||
|
|
||||||
|
# 只在低库存时发送通知
|
||||||
|
if normal <= TELEGRAM_LOW_STOCK_THRESHOLD:
|
||||||
|
text = self._format_stock_message(stats, is_alert=True)
|
||||||
|
for chat_id in TELEGRAM_ADMIN_CHAT_IDS:
|
||||||
|
try:
|
||||||
|
await context.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Stock check failed: {e}")
|
||||||
|
|
||||||
|
def _format_stock_message(self, stats: dict, is_alert: bool = False) -> str:
|
||||||
|
"""格式化存货消息"""
|
||||||
|
total = stats.get("total_accounts", 0)
|
||||||
|
normal = stats.get("normal_accounts", 0)
|
||||||
|
error = stats.get("error_accounts", 0)
|
||||||
|
ratelimit = stats.get("ratelimit_accounts", 0)
|
||||||
|
overload = stats.get("overload_accounts", 0)
|
||||||
|
|
||||||
|
# 计算健康度
|
||||||
|
health_pct = (normal / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
# 状态图标
|
||||||
|
if normal <= TELEGRAM_LOW_STOCK_THRESHOLD:
|
||||||
|
status_icon = "LOW STOCK"
|
||||||
|
status_line = f"<b>{status_icon}</b>"
|
||||||
|
elif health_pct >= 80:
|
||||||
|
status_icon = "OK"
|
||||||
|
status_line = f"<b>{status_icon}</b>"
|
||||||
|
elif health_pct >= 50:
|
||||||
|
status_icon = "WARN"
|
||||||
|
status_line = f"<b>{status_icon}</b>"
|
||||||
|
else:
|
||||||
|
status_icon = "CRITICAL"
|
||||||
|
status_line = f"<b>{status_icon}</b>"
|
||||||
|
|
||||||
|
title = "LOW STOCK ALERT" if is_alert else "Account Stock"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"<b>{title}</b>",
|
||||||
|
"",
|
||||||
|
f"Status: {status_line}",
|
||||||
|
f"Health: {health_pct:.1f}%",
|
||||||
|
"",
|
||||||
|
f"Normal: <b>{normal}</b>",
|
||||||
|
f"Error: {error}",
|
||||||
|
f"RateLimit: {ratelimit}",
|
||||||
|
f"Total: {total}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if is_alert:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Threshold: {TELEGRAM_LOW_STOCK_THRESHOLD}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def cmd_import(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""上传账号到 team.json"""
|
||||||
|
# 获取命令后的 JSON 数据
|
||||||
|
if not context.args:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"<b>Upload Accounts to team.json</b>\n\n"
|
||||||
|
"Usage:\n"
|
||||||
|
"1. Send a JSON file directly\n"
|
||||||
|
"2. /import followed by JSON data\n\n"
|
||||||
|
"JSON format:\n"
|
||||||
|
"<code>[{\"account\":\"email\",\"password\":\"pwd\",\"token\":\"jwt\"},...]</code>\n\n"
|
||||||
|
"After upload, use /run to start processing.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 尝试解析 JSON
|
||||||
|
json_text = " ".join(context.args)
|
||||||
|
await self._process_import_json(update, json_text)
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
async def handle_json_file(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""处理上传的 JSON 文件"""
|
||||||
|
# 检查是否是管理员
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
if user_id not in TELEGRAM_ADMIN_CHAT_IDS:
|
||||||
|
await update.message.reply_text("Unauthorized.")
|
||||||
|
return
|
||||||
|
|
||||||
|
document = update.message.document
|
||||||
|
if not document:
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text("Processing JSON file...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 下载文件
|
||||||
|
file = await document.get_file()
|
||||||
|
file_bytes = await file.download_as_bytearray()
|
||||||
|
json_text = file_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
await self._process_import_json(update, json_text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Error reading file: {e}")
|
||||||
|
|
||||||
|
async def _process_import_json(self, update: Update, json_text: str):
|
||||||
|
"""处理导入的 JSON 数据,保存到 team.json"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_accounts = json.loads(json_text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
await update.message.reply_text(f"Invalid JSON format: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(new_accounts, list):
|
||||||
|
# 如果是单个对象,转成列表
|
||||||
|
new_accounts = [new_accounts]
|
||||||
|
|
||||||
|
if not new_accounts:
|
||||||
|
await update.message.reply_text("No accounts in JSON data")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 验证格式
|
||||||
|
valid_accounts = []
|
||||||
|
for acc in new_accounts:
|
||||||
|
if not isinstance(acc, dict):
|
||||||
|
continue
|
||||||
|
# 支持 account 或 email 字段
|
||||||
|
email = acc.get("account") or acc.get("email", "")
|
||||||
|
token = acc.get("token", "")
|
||||||
|
password = acc.get("password", "")
|
||||||
|
|
||||||
|
if email and token:
|
||||||
|
valid_accounts.append({
|
||||||
|
"account": email,
|
||||||
|
"password": password,
|
||||||
|
"token": token
|
||||||
|
})
|
||||||
|
|
||||||
|
if not valid_accounts:
|
||||||
|
await update.message.reply_text("No valid accounts found (need account/email and token)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 读取现有 team.json
|
||||||
|
team_json_path = Path(TEAM_JSON_FILE)
|
||||||
|
existing_accounts = []
|
||||||
|
if team_json_path.exists():
|
||||||
|
try:
|
||||||
|
with open(team_json_path, "r", encoding="utf-8") as f:
|
||||||
|
existing_accounts = json.load(f)
|
||||||
|
if not isinstance(existing_accounts, list):
|
||||||
|
existing_accounts = [existing_accounts]
|
||||||
|
except Exception:
|
||||||
|
existing_accounts = []
|
||||||
|
|
||||||
|
# 检查重复
|
||||||
|
existing_emails = set()
|
||||||
|
for acc in existing_accounts:
|
||||||
|
email = acc.get("account") or acc.get("user", {}).get("email", "")
|
||||||
|
if email:
|
||||||
|
existing_emails.add(email.lower())
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
for acc in valid_accounts:
|
||||||
|
email = acc.get("account", "").lower()
|
||||||
|
if email in existing_emails:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
existing_accounts.append(acc)
|
||||||
|
existing_emails.add(email)
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# 保存到 team.json
|
||||||
|
try:
|
||||||
|
with open(team_json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(existing_accounts, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"<b>Upload Complete</b>\n\n"
|
||||||
|
f"Added: {added}\n"
|
||||||
|
f"Skipped (duplicate): {skipped}\n"
|
||||||
|
f"Total in team.json: {len(existing_accounts)}\n\n"
|
||||||
|
f"Use /run_all or /run <n> to start processing.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Error saving to team.json: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""主函数"""
|
||||||
|
if not TELEGRAM_ENABLED:
|
||||||
|
print("Telegram Bot is disabled. Set telegram.enabled = true in config.toml")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
print("Telegram Bot Token not configured. Set telegram.bot_token in config.toml")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not TELEGRAM_ADMIN_CHAT_IDS:
|
||||||
|
print("No admin chat IDs configured. Set telegram.admin_chat_ids in config.toml")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
bot = ProvisionerBot()
|
||||||
|
|
||||||
|
# 处理 Ctrl+C
|
||||||
|
import signal
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
log.info("Shutting down...")
|
||||||
|
bot.request_shutdown()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
await bot.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
363
utils.py
Normal file
363
utils.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# ==================== 工具函数模块 ====================
|
||||||
|
# 通用工具函数: CSV 记录、JSON 追踪等
|
||||||
|
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config import CSV_FILE, TEAM_TRACKER_FILE
|
||||||
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_csv(email: str, password: str, team_name: str = "", status: str = "success", crs_id: str = ""):
|
||||||
|
"""保存账号信息到 CSV 文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
password: 密码
|
||||||
|
team_name: Team 名称
|
||||||
|
status: 状态 (success/failed)
|
||||||
|
crs_id: CRS 账号 ID
|
||||||
|
"""
|
||||||
|
file_exists = os.path.exists(CSV_FILE)
|
||||||
|
|
||||||
|
with open(CSV_FILE, 'a', newline='', encoding='utf-8') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
|
||||||
|
if not file_exists:
|
||||||
|
writer.writerow(['email', 'password', 'team', 'status', 'crs_id', 'timestamp'])
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
team_name,
|
||||||
|
status,
|
||||||
|
crs_id,
|
||||||
|
datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
])
|
||||||
|
|
||||||
|
log.info(f"保存到 {CSV_FILE}", icon="save")
|
||||||
|
|
||||||
|
|
||||||
|
def load_team_tracker() -> dict:
|
||||||
|
"""加载 Team 追踪记录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"teams": {"team_name": [{"email": "...", "status": "..."}]}}
|
||||||
|
"""
|
||||||
|
if os.path.exists(TEAM_TRACKER_FILE):
|
||||||
|
try:
|
||||||
|
with open(TEAM_TRACKER_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"加载追踪记录失败: {e}")
|
||||||
|
|
||||||
|
return {"teams": {}, "last_updated": None}
|
||||||
|
|
||||||
|
|
||||||
|
def save_team_tracker(tracker: dict):
|
||||||
|
"""保存 Team 追踪记录"""
|
||||||
|
tracker["last_updated"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(TEAM_TRACKER_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(tracker, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"保存追踪记录失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def add_account_to_tracker(tracker: dict, team_name: str, email: str, status: str = "invited"):
|
||||||
|
"""添加账号到追踪记录
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tracker: 追踪记录
|
||||||
|
team_name: Team 名称
|
||||||
|
email: 邮箱地址
|
||||||
|
status: 状态 (invited/registered/authorized/completed)
|
||||||
|
"""
|
||||||
|
if team_name not in tracker["teams"]:
|
||||||
|
tracker["teams"][team_name] = []
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
for account in tracker["teams"][team_name]:
|
||||||
|
if account["email"] == email:
|
||||||
|
account["status"] = status
|
||||||
|
account["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 添加新记录
|
||||||
|
tracker["teams"][team_name].append({
|
||||||
|
"email": email,
|
||||||
|
"status": status,
|
||||||
|
"created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
"updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_status(tracker: dict, team_name: str, email: str, status: str):
|
||||||
|
"""更新账号状态"""
|
||||||
|
if team_name in tracker["teams"]:
|
||||||
|
for account in tracker["teams"][team_name]:
|
||||||
|
if account["email"] == email:
|
||||||
|
account["status"] = status
|
||||||
|
account["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def remove_account_from_tracker(tracker: dict, team_name: str, email: str) -> bool:
|
||||||
|
"""从 tracker 中移除账号
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tracker: 追踪记录
|
||||||
|
team_name: Team 名称
|
||||||
|
email: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功移除
|
||||||
|
"""
|
||||||
|
if team_name in tracker["teams"]:
|
||||||
|
original_len = len(tracker["teams"][team_name])
|
||||||
|
tracker["teams"][team_name] = [
|
||||||
|
acc for acc in tracker["teams"][team_name]
|
||||||
|
if acc["email"] != email
|
||||||
|
]
|
||||||
|
return len(tracker["teams"][team_name]) < original_len
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_account_count(tracker: dict, team_name: str) -> int:
|
||||||
|
"""获取 Team 已记录的账号数量"""
|
||||||
|
if team_name in tracker["teams"]:
|
||||||
|
return len(tracker["teams"][team_name])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_incomplete_accounts(tracker: dict, team_name: str) -> list:
|
||||||
|
"""获取未完成的账号列表 (非 completed 状态)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tracker: 追踪记录
|
||||||
|
team_name: Team 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{"email": "...", "status": "...", "password": "...", "role": "..."}]
|
||||||
|
"""
|
||||||
|
incomplete = []
|
||||||
|
if team_name in tracker.get("teams", {}):
|
||||||
|
for account in tracker["teams"][team_name]:
|
||||||
|
status = account.get("status", "")
|
||||||
|
# 只要不是 completed 都算未完成,需要继续处理
|
||||||
|
if status != "completed":
|
||||||
|
incomplete.append({
|
||||||
|
"email": account["email"],
|
||||||
|
"status": status,
|
||||||
|
"password": account.get("password", ""),
|
||||||
|
"role": account.get("role", "member") # 包含角色信息
|
||||||
|
})
|
||||||
|
return incomplete
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_incomplete_accounts(tracker: dict) -> dict:
|
||||||
|
"""获取所有 Team 的未完成账号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"team_name": [{"email": "...", "status": "..."}]}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for team_name in tracker.get("teams", {}):
|
||||||
|
incomplete = get_incomplete_accounts(tracker, team_name)
|
||||||
|
if incomplete:
|
||||||
|
result[team_name] = incomplete
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def add_account_with_password(tracker: dict, team_name: str, email: str, password: str, status: str = "invited"):
|
||||||
|
"""添加账号到追踪记录 (带密码)"""
|
||||||
|
if team_name not in tracker["teams"]:
|
||||||
|
tracker["teams"][team_name] = []
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
for account in tracker["teams"][team_name]:
|
||||||
|
if account["email"] == email:
|
||||||
|
account["status"] = status
|
||||||
|
account["password"] = password
|
||||||
|
account["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 添加新记录
|
||||||
|
tracker["teams"][team_name].append({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"status": status,
|
||||||
|
"role": "member", # 角色: owner 或 member
|
||||||
|
"created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
"updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(results: list):
|
||||||
|
"""打印执行摘要
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: [{"team": "...", "email": "...", "status": "...", "crs_id": "..."}]
|
||||||
|
"""
|
||||||
|
log.separator("=", 60)
|
||||||
|
log.header("执行摘要")
|
||||||
|
log.separator("=", 60)
|
||||||
|
|
||||||
|
success_count = sum(1 for r in results if r.get("status") == "success")
|
||||||
|
failed_count = len(results) - success_count
|
||||||
|
|
||||||
|
log.info(f"总计: {len(results)} 个账号")
|
||||||
|
log.success(f"成功: {success_count}")
|
||||||
|
log.error(f"失败: {failed_count}")
|
||||||
|
|
||||||
|
# 按 Team 分组
|
||||||
|
teams = {}
|
||||||
|
for r in results:
|
||||||
|
team = r.get("team", "Unknown")
|
||||||
|
if team not in teams:
|
||||||
|
teams[team] = {"success": 0, "failed": 0, "accounts": []}
|
||||||
|
|
||||||
|
if r.get("status") == "success":
|
||||||
|
teams[team]["success"] += 1
|
||||||
|
else:
|
||||||
|
teams[team]["failed"] += 1
|
||||||
|
|
||||||
|
teams[team]["accounts"].append(r)
|
||||||
|
|
||||||
|
log.info("按 Team 统计:")
|
||||||
|
for team_name, data in teams.items():
|
||||||
|
log.info(f"{team_name}: 成功 {data['success']}, 失败 {data['failed']}", icon="team")
|
||||||
|
for acc in data["accounts"]:
|
||||||
|
if acc.get("status") == "success":
|
||||||
|
log.success(f"{acc.get('email', 'Unknown')}")
|
||||||
|
else:
|
||||||
|
log.error(f"{acc.get('email', 'Unknown')}")
|
||||||
|
|
||||||
|
log.separator("=", 60)
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
"""格式化时长"""
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds:.1f}s"
|
||||||
|
elif seconds < 3600:
|
||||||
|
minutes = seconds / 60
|
||||||
|
return f"{minutes:.1f}m"
|
||||||
|
else:
|
||||||
|
hours = seconds / 3600
|
||||||
|
return f"{hours:.1f}h"
|
||||||
|
|
||||||
|
|
||||||
|
class Timer:
|
||||||
|
"""计时器"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = ""):
|
||||||
|
self.name = name
|
||||||
|
self.start_time = None
|
||||||
|
self.end_time = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.start_time = time.time()
|
||||||
|
if self.name:
|
||||||
|
log.info(f"{self.name} 开始", icon="time")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.end_time = time.time()
|
||||||
|
duration = self.end_time - self.start_time
|
||||||
|
if self.name:
|
||||||
|
log.info(f"{self.name} 完成 ({format_duration(duration)})", icon="time")
|
||||||
|
return duration
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def add_team_owners_to_tracker(tracker: dict, password: str) -> int:
|
||||||
|
"""将 team.json 中的 Team Owner 添加到 tracker,走授权流程
|
||||||
|
|
||||||
|
只添加有 token 的 Team Owner,没有 token 的跳过(格式3会在登录时单独处理)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tracker: team_tracker 数据
|
||||||
|
password: 默认账号密码 (旧格式使用)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 添加的数量
|
||||||
|
"""
|
||||||
|
from config import INCLUDE_TEAM_OWNERS, TEAMS
|
||||||
|
|
||||||
|
if not INCLUDE_TEAM_OWNERS:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not TEAMS:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
added_count = 0
|
||||||
|
for team in TEAMS:
|
||||||
|
# 跳过没有 token 的 Team(格式3会在登录时单独处理)
|
||||||
|
if not team.get("auth_token"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
team_name = team.get("name", "")
|
||||||
|
team_format = team.get("format", "old")
|
||||||
|
|
||||||
|
# 获取邮箱
|
||||||
|
email = team.get("owner_email", "")
|
||||||
|
if not email:
|
||||||
|
raw_data = team.get("raw", {})
|
||||||
|
email = raw_data.get("user", {}).get("email", "")
|
||||||
|
|
||||||
|
# 获取密码
|
||||||
|
owner_password = team.get("owner_password", "") or password
|
||||||
|
|
||||||
|
if not team_name or not email:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否已在 tracker 中
|
||||||
|
existing = False
|
||||||
|
if team_name in tracker.get("teams", {}):
|
||||||
|
for acc in tracker["teams"][team_name]:
|
||||||
|
if acc.get("email") == email:
|
||||||
|
existing = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# 添加到 tracker
|
||||||
|
if team_name not in tracker["teams"]:
|
||||||
|
tracker["teams"][team_name] = []
|
||||||
|
|
||||||
|
# 根据格式和授权状态决定 tracker 状态
|
||||||
|
# - 新格式且已授权: 状态为 completed (跳过)
|
||||||
|
# - 新格式未授权: 状态为 registered (需要授权)
|
||||||
|
# - 旧格式: 使用 OTP 登录授权,状态为 team_owner
|
||||||
|
if team_format == "new":
|
||||||
|
if team.get("authorized"):
|
||||||
|
status = "completed" # 已授权,跳过
|
||||||
|
else:
|
||||||
|
status = "registered" # 需要授权
|
||||||
|
else:
|
||||||
|
status = "team_owner" # 旧格式,使用 OTP 登录授权
|
||||||
|
|
||||||
|
tracker["teams"][team_name].append({
|
||||||
|
"email": email,
|
||||||
|
"password": owner_password,
|
||||||
|
"status": status,
|
||||||
|
"role": "owner",
|
||||||
|
"created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
"updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
log.info(f"Team Owner 添加到 tracker: {email} -> {team_name} (格式: {team_format}, 状态: {status})")
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
if added_count > 0:
|
||||||
|
log.info(f"已添加 {added_count} 个 Team Owner 到 tracker", icon="sync")
|
||||||
|
|
||||||
|
return added_count
|
||||||
535
uv.lock
generated
Normal file
535
uv.lock
generated
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ 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, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.11.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssselect"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870, upload-time = "2025-03-10T09:30:29.638Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786, upload-time = "2025-03-10T09:30:28.048Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "datarecorder"
|
||||||
|
version = "3.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "openpyxl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/a1/43d52eada4e1364f8d1a5da99b855798f945228ffb7dfb199f675c1d2c2b/DataRecorder-3.6.2.tar.gz", hash = "sha256:8c9024736692af68b947fd884589e685c4f27bc29d031b81164457b09c60e1e5", size = 29567, upload-time = "2024-10-15T08:58:36.923Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/05/f0064d93a0b922ea7ad5d40a0dfaac45de37f783a85a2900e6ce8a01dbc3/DataRecorder-3.6.2-py3-none-any.whl", hash = "sha256:41ad022c4c1db58a0f4236f6a4991d1f98cd76ec049484b172bbb3040455e5ff", size = 37539, upload-time = "2024-10-15T08:58:35.419Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downloadkit"
|
||||||
|
version = "2.0.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "datarecorder" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6b/35/9bbaed54f5d170b1fd8bcfdd3ce5e24ea71f4cac5026a37e6b21ab69726e/DownloadKit-2.0.7.tar.gz", hash = "sha256:601e423d1d4d0bd3e933524d06c913e744577d455990ec20afc3fb1f79aea9a7", size = 17488, upload-time = "2024-11-30T01:24:30.539Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/89/979ad2407eb8e0a2ac67461b7bec7db8627218fe3ba287db49636ca0a60c/DownloadKit-2.0.7-py3-none-any.whl", hash = "sha256:55b142d26b4e7ae01fff2181991c92aca9b895f75e2cd8a54f4a3b48c50b90b4", size = 21706, upload-time = "2024-11-30T01:24:17.599Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "drissionpage"
|
||||||
|
version = "4.1.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "cssselect" },
|
||||||
|
{ name = "downloadkit" },
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "psutil" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "tldextract" },
|
||||||
|
{ name = "websocket-client" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/12/b3/26bb358f3c7019bba0a2e27a636382a4226780628c30ab315537524809fb/drissionpage-4.1.1.2.tar.gz", hash = "sha256:e4375d3536519a1d24430270e2871de3015836b301cbdfb96583234e6d6ef4c1", size = 208034, upload-time = "2025-07-31T07:00:36.24Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/31/05834b38c12a222012f998d5c2f104f228cf8aaa37747f989c9b29e81d5d/DrissionPage-4.1.1.2-py3-none-any.whl", hash = "sha256:c8b58a8d495550142c10499ce7128655b2baf8f38f46604ba0ce951a148d88e8", size = 257497, upload-time = "2025-07-31T07:00:34.531Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "et-xmlfile"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.20.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml"
|
||||||
|
version = "6.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oai-team-auto-provisioner"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "drissionpage" },
|
||||||
|
{ name = "python-telegram-bot" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
{ name = "tomli" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "drissionpage", specifier = ">=4.1.1.2" },
|
||||||
|
{ name = "python-telegram-bot", specifier = ">=22.5" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
{ name = "rich", specifier = ">=14.2.0" },
|
||||||
|
{ name = "setuptools", specifier = ">=80.9.0" },
|
||||||
|
{ name = "tomli", specifier = ">=2.3.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openpyxl"
|
||||||
|
version = "3.1.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "et-xmlfile" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-telegram-bot"
|
||||||
|
version = "22.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/6b/400f88e5c29a270c1c519a3ca8ad0babc650ec63dbfbd1b73babf625ed54/python_telegram_bot-22.5.tar.gz", hash = "sha256:82d4efd891d04132f308f0369f5b5929e0b96957901f58bcef43911c5f6f92f8", size = 1488269, upload-time = "2025-09-27T13:50:27.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/c3/340c7520095a8c79455fcf699cbb207225e5b36490d2b9ee557c16a7b21b/python_telegram_bot-22.5-py3-none-any.whl", hash = "sha256:4b7cd365344a7dce54312cc4520d7fa898b44d1a0e5f8c74b5bd9b540d035d16", size = 730976, upload-time = "2025-09-27T13:50:25.93Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests-file"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "14.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "setuptools"
|
||||||
|
version = "80.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tldextract"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "requests-file" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/97/78/182641ea38e3cfd56e9c7b3c0d48a53d432eea755003aa544af96403d4ac/tldextract-5.3.0.tar.gz", hash = "sha256:b3d2b70a1594a0ecfa6967d57251527d58e00bb5a91a74387baa0d87a0678609", size = 128502, upload-time = "2025-04-22T06:19:37.491Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/7c/ea488ef48f2f544566947ced88541bc45fae9e0e422b2edbf165ee07da99/tldextract-5.3.0-py3-none-any.whl", hash = "sha256:f70f31d10b55c83993f55e91ecb7c5d84532a8972f22ec578ecfbe5ea2292db2", size = 107384, upload-time = "2025-04-22T06:19:36.304Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websocket-client"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user