From 06f906abc72797882af88872246942dd395d0798 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Thu, 15 Jan 2026 23:02:16 +0800 Subject: [PATCH] first --- .gitignore | 35 + .python-version | 1 + README.md | 459 ++++++++ bot_notifier.py | 319 ++++++ browser_automation.py | 2511 +++++++++++++++++++++++++++++++++++++++++ config.py | 510 +++++++++ config.toml.example | 221 ++++ cpa_service.py | 309 +++++ crs_service.py | 374 ++++++ email_service.py | 640 +++++++++++ logger.py | 300 +++++ pyproject.toml | 14 + requirements.txt | 6 + run.py | 818 ++++++++++++++ s2a_service.py | 736 ++++++++++++ team.json.example | 40 + team_service.py | 426 +++++++ telegram_bot.py | 652 +++++++++++ utils.py | 363 ++++++ uv.lock | 535 +++++++++ 20 files changed, 9269 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 bot_notifier.py create mode 100644 browser_automation.py create mode 100644 config.py create mode 100644 config.toml.example create mode 100644 cpa_service.py create mode 100644 crs_service.py create mode 100644 email_service.py create mode 100644 logger.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 s2a_service.py create mode 100644 team.json.example create mode 100644 team_service.py create mode 100644 telegram_bot.py create mode 100644 utils.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..622e340 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a66819 --- /dev/null +++ b/README.md @@ -0,0 +1,459 @@ +# 🚀 OpenAI Team Auto Provisioner + +
+ +**OpenAI Team 账号自动批量注册 & 授权入库工具** + +[![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://www.python.org/) +[![DrissionPage](https://img.shields.io/badge/DrissionPage-4.1+-green.svg)](https://drissionpage.cn/) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +
+ +--- + +## ✨ 功能特性 + +- 🔄 **全自动化流程** - 从邮箱创建到授权入库一键完成 +- 📧 **多邮箱系统支持** - 支持 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) diff --git a/bot_notifier.py b/bot_notifier.py new file mode 100644 index 0000000..c4eddb9 --- /dev/null +++ b/bot_notifier.py @@ -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"Processing: {self.team_name}", + "", + 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: {self.current_account}") + + 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"Task Started\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"Task Completed\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"Error\n{message}" + if details: + text += f"\n{details[:500]}" + 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"{team_name}\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() diff --git a/browser_automation.py b/browser_automation.py new file mode 100644 index 0000000..c01fb6e --- /dev/null +++ b/browser_automation.py @@ -0,0 +1,2511 @@ +# ==================== 浏览器自动化模块 ==================== +# 处理 OpenAI 注册、Codex 授权等浏览器自动化操作 +# 使用 DrissionPage 替代 Selenium + +import time +import random +import subprocess +import os +from contextlib import contextmanager +from DrissionPage import ChromiumPage, ChromiumOptions + +from config import ( + BROWSER_WAIT_TIMEOUT, + BROWSER_SHORT_WAIT, + BROWSER_HEADLESS, + AUTH_PROVIDER, + get_random_name, + get_random_birthday +) +from email_service import unified_get_verification_code +from crs_service import crs_generate_auth_url, crs_exchange_code, crs_add_account, extract_code_from_url +from cpa_service import ( + cpa_generate_auth_url, + cpa_submit_callback, + cpa_poll_auth_status, + is_cpa_callback_url +) +from logger import log + + +# ==================== 浏览器配置常量 ==================== +BROWSER_MAX_RETRIES = 3 # 浏览器启动最大重试次数 +BROWSER_RETRY_DELAY = 2 # 重试间隔 (秒) +PAGE_LOAD_TIMEOUT = 15 # 页面加载超时 (秒) + +# ==================== 输入速度配置 (模拟真人) ==================== +# 设置为 True 使用更安全的慢速模式,False 使用快速模式 +SAFE_MODE = True +TYPING_DELAY = 0.12 if SAFE_MODE else 0.06 # 打字基础延迟 +ACTION_DELAY = (1.0, 2.0) if SAFE_MODE else (0.3, 0.8) # 操作间隔范围 + + +# ==================== URL 监听与日志 ==================== +_last_logged_url = None # 记录上次日志的URL,避免重复 + + +def log_current_url(page, context: str = None, force: bool = False): + """记录当前页面URL (完整地址) + + Args: + page: 浏览器页面对象 + context: 上下文描述 (如 "点击继续后", "输入邮箱后") + force: 是否强制记录 (即使URL未变化) + """ + global _last_logged_url + try: + current_url = page.url + # 只在URL变化时记录,除非强制记录 + if force or current_url != _last_logged_url: + _last_logged_url = current_url + + # 解析URL获取关键信息 + url_info = _parse_url_info(current_url) + + # 左对齐格式输出 + if context: + if url_info: + log.info(f"[URL] {context} | {current_url} | {url_info}") + else: + log.info(f"[URL] {context} | {current_url}") + else: + if url_info: + log.info(f"[URL] {current_url} | {url_info}") + else: + log.info(f"[URL] {current_url}") + except Exception as e: + log.warning(f"获取URL失败: {e}") + + +def _parse_url_info(url: str) -> str: + """解析URL,返回页面类型描述 + + Args: + url: 页面URL + + Returns: + str: 页面类型描述 + """ + if not url: + return "" + + # OpenAI Auth 页面 + if "auth.openai.com" in url: + if "/log-in-or-create-account" in url: + return "登录/注册选择页" + elif "/log-in/password" in url: + return "密码登录页" + elif "/create-account/password" in url: + return "创建账号密码页" + elif "/email-verification" in url: + return "邮箱验证码页" + elif "/about-you" in url: + return "个人信息填写页" + elif "/authorize" in url: + return "授权确认页" + elif "/callback" in url: + return "回调处理页" + else: + return "OpenAI 认证页" + + # ChatGPT 页面 + elif "chatgpt.com" in url: + if "/auth" in url: + return "ChatGPT 认证页" + else: + return "ChatGPT 主页" + + # 回调页面 + elif "localhost:1455" in url: + if "/auth/callback" in url: + return "本地授权回调页" + else: + return "本地服务页" + + return "" + + +def log_url_change(page, old_url: str, action: str = None): + """记录URL变化 (显示完整地址,左对齐) + + Args: + page: 浏览器页面对象 + old_url: 变化前的URL + action: 触发变化的操作描述 + """ + global _last_logged_url + try: + new_url = page.url + if new_url != old_url: + _last_logged_url = new_url # 更新记录,避免重复日志 + new_info = _parse_url_info(new_url) + + # 左对齐格式: [URL] 操作 | 新地址 | 页面类型 + if action: + if new_info: + log.info(f"[URL] {action} | {new_url} | {new_info}") + else: + log.info(f"[URL] {action} | {new_url}") + else: + if new_info: + log.info(f"[URL] 跳转 | {new_url} | {new_info}") + else: + log.info(f"[URL] 跳转 | {new_url}") + except Exception as e: + log.warning(f"记录URL变化失败: {e}") + + +def cleanup_chrome_processes(): + """清理残留的 Chrome 进程 (Windows)""" + try: + # 查找并终止残留的 chrome 进程 (仅限无头或调试模式的) + result = subprocess.run( + ['tasklist', '/FI', 'IMAGENAME eq chrome.exe', '/FO', 'CSV'], + capture_output=True, text=True, timeout=5 + ) + + if 'chrome.exe' in result.stdout: + # 只清理可能是自动化残留的进程,不影响用户正常使用的浏览器 + # 通过检查命令行参数来判断 + subprocess.run( + ['taskkill', '/F', '/IM', 'chromedriver.exe'], + capture_output=True, timeout=5 + ) + log.step("已清理 chromedriver 残留进程") + except Exception: + pass # 静默处理,不影响主流程 + + +def init_browser(max_retries: int = BROWSER_MAX_RETRIES) -> ChromiumPage: + """初始化 DrissionPage 浏览器 (带重试机制) + + Args: + max_retries: 最大重试次数 + + Returns: + ChromiumPage: 浏览器实例 + """ + log.info("初始化浏览器...", icon="browser") + + last_error = None + + for attempt in range(max_retries): + try: + # 首次尝试或重试前清理残留进程 + if attempt > 0: + log.warning(f"浏览器启动重试 ({attempt + 1}/{max_retries})...") + cleanup_chrome_processes() + time.sleep(BROWSER_RETRY_DELAY) + + co = ChromiumOptions() + co.set_argument('--no-first-run') + co.set_argument('--disable-infobars') + co.set_argument('--incognito') # 无痕模式 + co.set_argument('--disable-gpu') # 减少资源占用 + co.set_argument('--disable-dev-shm-usage') # 避免共享内存问题 + co.set_argument('--no-sandbox') # 服务器环境需要 + co.auto_port() # 自动分配端口,确保每次都是新实例 + + # 无头模式 (服务器运行) + if BROWSER_HEADLESS: + co.set_argument('--headless=new') + co.set_argument('--window-size=1920,1080') + log.step("启动 Chrome (无头模式)...") + else: + log.step("启动 Chrome (无痕模式)...") + + # 设置超时 + co.set_timeouts(base=PAGE_LOAD_TIMEOUT, page_load=PAGE_LOAD_TIMEOUT * 2) + + page = ChromiumPage(co) + log.success("浏览器启动成功") + return page + + except Exception as e: + last_error = e + log.warning(f"浏览器启动失败 (尝试 {attempt + 1}/{max_retries}): {e}") + + # 清理可能的残留 + cleanup_chrome_processes() + + # 所有重试都失败 + log.error(f"浏览器启动失败,已重试 {max_retries} 次: {last_error}") + raise last_error + + +@contextmanager +def browser_context(max_retries: int = BROWSER_MAX_RETRIES): + """浏览器上下文管理器 - 自动管理浏览器生命周期 + + 使用示例: + with browser_context() as page: + page.get("https://example.com") + # 做一些操作... + # 浏览器会自动关闭 + + Args: + max_retries: 浏览器启动最大重试次数 + + Yields: + ChromiumPage: 浏览器页面实例 + """ + page = None + try: + page = init_browser(max_retries) + yield page + finally: + if page: + log.step("关闭浏览器...") + try: + page.quit() + except Exception as e: + log.warning(f"浏览器关闭异常: {e}") + finally: + # 确保清理残留进程 + cleanup_chrome_processes() + + +@contextmanager +def browser_context_with_retry(max_browser_retries: int = 2): + """带重试机制的浏览器上下文管理器 + + 在整体流程失败时自动重试,适用于注册/授权等复杂流程 + + 使用示例: + with browser_context_with_retry() as ctx: + for attempt in ctx.attempts(): + try: + page = ctx.page + # 做一些操作... + break # 成功则退出 + except Exception as e: + ctx.handle_error(e) + + Args: + max_browser_retries: 最大重试次数 + + Yields: + BrowserRetryContext: 重试上下文对象 + """ + ctx = BrowserRetryContext(max_browser_retries) + try: + yield ctx + finally: + ctx.cleanup() + + +class BrowserRetryContext: + """浏览器重试上下文""" + + def __init__(self, max_retries: int = 2): + self.max_retries = max_retries + self.current_attempt = 0 + self.page = None + self._should_continue = True + + def attempts(self): + """生成重试迭代器""" + for attempt in range(self.max_retries): + if not self._should_continue: + break + + self.current_attempt = attempt + + # 非首次尝试时的清理和等待 + if attempt > 0: + log.warning(f"重试整体流程 ({attempt + 1}/{self.max_retries})...") + self._cleanup_page() + cleanup_chrome_processes() + time.sleep(2) + + # 初始化浏览器 + try: + self.page = init_browser() + yield attempt + except Exception as e: + log.error(f"浏览器初始化失败: {e}") + if attempt >= self.max_retries - 1: + raise + + def handle_error(self, error: Exception): + """处理错误,决定是否继续重试""" + log.error(f"流程异常: {error}") + if self.current_attempt >= self.max_retries - 1: + self._should_continue = False + else: + log.warning("准备重试...") + + def stop(self): + """停止重试""" + self._should_continue = False + + def _cleanup_page(self): + """清理当前页面""" + if self.page: + try: + self.page.quit() + except Exception: + pass + self.page = None + + def cleanup(self): + """最终清理""" + if self.page: + log.step("关闭浏览器...") + try: + self.page.quit() + except Exception: + pass + self.page = None + + +def wait_for_page_stable(page, timeout: int = 10, check_interval: float = 0.5) -> bool: + """等待页面稳定 (页面加载完成且 DOM 不再变化) + + Args: + page: 浏览器页面对象 + timeout: 超时时间 (秒) + check_interval: 检查间隔 (秒) + + Returns: + bool: 是否稳定 + """ + start_time = time.time() + last_html_len = 0 + stable_count = 0 + + while time.time() - start_time < timeout: + try: + # 检查浏览器标签页是否还在加载(favicon 旋转动画) + ready_state = page.run_js('return document.readyState', timeout=2) + if ready_state != 'complete': + stable_count = 0 + time.sleep(check_interval) + continue + + current_len = len(page.html) + if current_len == last_html_len: + stable_count += 1 + if stable_count >= 3: # 连续 3 次检查都稳定 + return True + else: + stable_count = 0 + last_html_len = current_len + time.sleep(check_interval) + except Exception: + time.sleep(check_interval) + + return False + + +def check_and_handle_error_page(page, max_retries: int = 2) -> bool: + """检测并处理错误页面(如 Operation timed out) + + Args: + page: 浏览器页面对象 + max_retries: 最大重试次数 + + Returns: + bool: 是否成功处理(页面恢复正常) + """ + for attempt in range(max_retries): + # 检测错误页面 + error_text = page.ele('text:糟糕,出错了', timeout=1) or \ + page.ele('text:Something went wrong', timeout=1) or \ + page.ele('text:Operation timed out', timeout=1) + + if not error_text: + return True # 没有错误,正常 + + log.warning(f"检测到错误页面,尝试重试 ({attempt + 1}/{max_retries})...") + + # 点击重试按钮 + retry_btn = page.ele('text:重试', timeout=2) or page.ele('text:Retry', timeout=1) + if retry_btn: + retry_btn.click() + time.sleep(3) + wait_for_page_stable(page, timeout=8) + else: + # 没有重试按钮,刷新页面 + page.refresh() + time.sleep(3) + wait_for_page_stable(page, timeout=8) + + # 最后再检查一次 + error_text = page.ele('text:糟糕,出错了', timeout=1) or page.ele('text:Something went wrong', timeout=1) + return error_text is None + + +def wait_for_element(page, selector: str, timeout: int = 10, visible: bool = True): + """智能等待元素出现 + + Args: + page: 浏览器页面对象 + selector: CSS 选择器 + timeout: 超时时间 (秒) + visible: 是否要求元素可见 + + Returns: + 元素对象或 None + """ + start_time = time.time() + + while time.time() - start_time < timeout: + try: + element = page.ele(selector, timeout=1) + if element: + if not visible or (element.states.is_displayed if hasattr(element, 'states') else True): + return element + except Exception: + pass + time.sleep(0.3) + + return None + + +def wait_for_url_change(page, old_url: str, timeout: int = 15, contains: str = None) -> bool: + """等待 URL 变化 + + Args: + page: 浏览器页面对象 + old_url: 原始 URL + timeout: 超时时间 (秒) + contains: 新 URL 需要包含的字符串 (可选) + + Returns: + bool: URL 是否已变化 + """ + start_time = time.time() + + while time.time() - start_time < timeout: + try: + current_url = page.url + if current_url != old_url: + if contains is None or contains in current_url: + return True + except Exception: + pass + time.sleep(0.5) + + return False + + +def type_slowly(page, selector_or_element, text, base_delay=None): + """缓慢输入文本 (模拟真人输入) + + Args: + page: 浏览器页面对象 (用于重新获取元素) + selector_or_element: CSS 选择器字符串或元素对象 + text: 要输入的文本 + base_delay: 基础延迟 (秒),默认使用 TYPING_DELAY + """ + if base_delay is None: + base_delay = TYPING_DELAY + + # 获取元素 (如果传入的是选择器则查找,否则直接使用) + if isinstance(selector_or_element, str): + element = page.ele(selector_or_element, timeout=10) + else: + element = selector_or_element + + if not text: + return + + # 对于短文本(如验证码),直接一次性输入,速度更快 + if len(text) <= 8: + element.input(text, clear=True) + return + + # 长文本使用逐字符输入 + element.input(text[0], clear=True) + time.sleep(random.uniform(0.1, 0.2)) + + # 逐个输入剩余字符,不重新获取元素 + for char in text[1:]: + element.input(char, clear=False) + # 随机延迟 + actual_delay = base_delay * random.uniform(0.5, 1.2) + if char in ' @._-': + actual_delay *= 1.3 + time.sleep(actual_delay) + + +def human_delay(min_sec: float = None, max_sec: float = None): + """模拟人类操作间隔 + + Args: + min_sec: 最小延迟 (秒),默认使用 ACTION_DELAY[0] + max_sec: 最大延迟 (秒),默认使用 ACTION_DELAY[1] + """ + if min_sec is None: + min_sec = ACTION_DELAY[0] + if max_sec is None: + max_sec = ACTION_DELAY[1] + time.sleep(random.uniform(min_sec, max_sec)) + + +def check_and_handle_error(page, max_retries=5) -> bool: + """检查并处理页面错误 (带自动重试)""" + for attempt in range(max_retries): + try: + page_source = page.html.lower() + error_keywords = ['出错', 'error', 'timed out', 'operation timeout', 'route error', 'invalid content'] + has_error = any(keyword in page_source for keyword in error_keywords) + + if has_error: + try: + retry_btn = page.ele('css:button[data-dd-action-name="Try again"]', timeout=2) + if retry_btn: + log.warning(f"检测到错误页面,点击重试 ({attempt + 1}/{max_retries})...") + retry_btn.click() + wait_time = 3 + attempt # 递增等待,但减少基础时间 + time.sleep(wait_time) + return True + except Exception: + time.sleep(1) + continue + return False + except Exception: + return False + return False + + +def retry_on_page_refresh(func): + """装饰器: 页面刷新时自动重试""" + def wrapper(*args, **kwargs): + max_retries = 3 + for attempt in range(max_retries): + try: + return func(*args, **kwargs) + except Exception as e: + error_msg = str(e).lower() + if '页面被刷新' in error_msg or 'page refresh' in error_msg or 'stale' in error_msg: + if attempt < max_retries - 1: + log.warning(f"页面刷新,重试操作 ({attempt + 1}/{max_retries})...") + time.sleep(1) + continue + raise + return None + return wrapper + + +def is_logged_in(page, timeout: int = 5) -> bool: + """检测是否已登录 ChatGPT (通过 API 请求判断) + + 通过请求 /api/auth/session 接口判断: + - 已登录: 返回包含 user 字段的 JSON + - 未登录: 返回 {} + """ + try: + # 使用 JavaScript 请求 session API,设置超时 + result = page.run_js(f''' + return Promise.race([ + fetch('/api/auth/session', {{ + method: 'GET', + credentials: 'include' + }}) + .then(r => r.json()) + .then(data => JSON.stringify(data)) + .catch(e => '{{}}'), + new Promise((_, reject) => setTimeout(() => reject('timeout'), {timeout * 1000})) + ]).catch(() => '{{}}'); + ''', timeout=timeout + 2) + + if result and result != '{}': + import json + data = json.loads(result) + if data.get('user') and data.get('accessToken'): + log.success(f"已登录: {data['user'].get('email', 'unknown')}") + return True + return False + except Exception as e: + log.warning(f"登录检测异常: {e}") + return False + + +def register_openai_account(page, email: str, password: str) -> bool: + """使用浏览器注册 OpenAI 账号 + + Args: + page: 浏览器实例 + email: 邮箱地址 + password: 密码 + + Returns: + bool: 是否成功 + """ + log.info(f"开始注册 OpenAI 账号: {email}", icon="account") + + try: + # 打开注册页面 + url = "https://chatgpt.com" + log.step(f"打开 {url}") + page.get(url) + + # 智能等待页面加载完成 + wait_for_page_stable(page, timeout=8) + log_current_url(page, "页面加载完成", force=True) + + # 检查页面是否正常加载 + current_url = page.url + + # 如果已经在 auth.openai.com,说明页面正常,直接继续 + if "auth.openai.com" in current_url: + log.info("已跳转到认证页面") + else: + # 在 chatgpt.com,检查是否有注册按钮 + page_ok = page.ele('css:[data-testid="signup-button"]', timeout=1) or \ + page.ele('text:免费注册', timeout=1) or \ + page.ele('text:Sign up', timeout=1) or \ + page.ele('text:登录', timeout=1) # 也可能显示登录按钮 + if not page_ok: + log.warning("页面加载异常,3秒后刷新...") + time.sleep(3) + page.refresh() + wait_for_page_stable(page, timeout=8) + log_current_url(page, "刷新后", force=True) + + # 检测是否已登录 (通过 API 判断) + try: + if is_logged_in(page): + log.success("检测到已登录,跳过注册步骤") + return True + except Exception: + pass # 忽略登录检测异常,继续注册流程 + + # 点击"免费注册"按钮 + log.step("点击免费注册...") + signup_btn = wait_for_element(page, 'css:[data-testid="signup-button"]', timeout=5) + if not signup_btn: + signup_btn = wait_for_element(page, 'text:免费注册', timeout=3) + if not signup_btn: + signup_btn = wait_for_element(page, 'text:Sign up', timeout=3) + if signup_btn: + old_url = page.url + signup_btn.click() + # 等待 URL 变化或弹窗/输入框出现 (最多3秒快速检测) + for _ in range(6): + time.sleep(0.5) + if page.url != old_url: + log_url_change(page, old_url, "点击注册按钮") + break + # 检测弹窗中的邮箱输入框 + try: + email_input = page.ele('css:input[type="email"], input[name="email"]', timeout=1) + if email_input and email_input.states.is_displayed: + break + except Exception: + pass + + current_url = page.url + log_current_url(page, "注册按钮点击后") + + # 如果没有跳转到 auth.openai.com,检查是否在 chatgpt.com 弹窗中 + if "auth.openai.com" not in current_url and "chatgpt.com" in current_url: + log.step("尝试在当前弹窗中输入邮箱...") + + # 快速检查弹窗是否正常加载(包含登录表单) + login_form = wait_for_element(page, 'css:[data-testid="login-form"]', timeout=1) + if not login_form: + login_form = page.ele('text:登录或注册', timeout=1) or page.ele('text:Log in or sign up', timeout=1) + + if not login_form: + # 弹窗内容异常,关闭并刷新页面重试 + log.warning("弹窗内容异常,刷新页面重试...") + close_btn = page.ele('css:button[aria-label="Close"], button[aria-label="关闭"]', timeout=1) + if not close_btn: + close_btn = page.ele('css:button:has(svg)', timeout=1) + if close_btn: + close_btn.click() + time.sleep(0.5) + + # 刷新页面 + page.refresh() + wait_for_page_stable(page, timeout=8) + log_current_url(page, "刷新后", force=True) + + # 重新点击注册按钮 + log.step("重新点击免费注册...") + signup_btn = wait_for_element(page, 'css:[data-testid="signup-button"]', timeout=5) or \ + wait_for_element(page, 'text:免费注册', timeout=3) + if signup_btn: + signup_btn.click() + time.sleep(2) + # 再次检查弹窗 + login_form = page.ele('css:[data-testid="login-form"]', timeout=3) or \ + page.ele('text:登录或注册', timeout=2) + if not login_form: + log.error("重试后弹窗仍然异常,跳过此账号") + return False + else: + log.error("找不到注册按钮,跳过此账号") + return False + + # 尝试输入邮箱 + email_input = wait_for_element(page, 'css:input[type="email"], input[name="email"], input[id="email"]', timeout=5) + if email_input: + human_delay() + type_slowly(page, 'css:input[type="email"], input[name="email"], input[id="email"]', email) + log.success("邮箱已输入") + + # 点击继续 + human_delay(0.5, 1.0) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=10, contains="/password") + + # === 使用循环处理整个注册流程 === + max_steps = 10 # 防止无限循环 + for step in range(max_steps): + current_url = page.url + log_current_url(page, f"注册流程步骤 {step + 1}") + + # 如果在 chatgpt.com 且已登录,注册成功 + if "chatgpt.com" in current_url and "auth.openai.com" not in current_url: + try: + if is_logged_in(page): + log.success("检测到已登录,账号已注册成功") + return True + except Exception: + pass + + # 步骤1: 输入邮箱 (在 log-in-or-create-account 页面) + if "auth.openai.com/log-in-or-create-account" in current_url: + log.step("等待邮箱输入框...") + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=15) + if not email_input: + log.error("无法找到邮箱输入框") + return False + + human_delay() # 模拟人类思考时间 + log.step("输入邮箱...") + type_slowly(page, 'css:input[type="email"]', email) + log.success("邮箱已输入") + + # 点击继续 + human_delay(0.5, 1.2) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + + # 步骤2: 输入密码 (在密码页面: log-in/password 或 create-account/password) + if "auth.openai.com/log-in/password" in current_url or "auth.openai.com/create-account/password" in current_url: + # 先检查是否有密码错误提示,如果有则使用一次性验证码登录 + try: + error_text = page.ele('text:Incorrect email address or password', timeout=1) + if error_text and error_text.states.is_displayed: + log.warning("密码错误,尝试使用一次性验证码登录...") + otp_btn = wait_for_element(page, 'text=使用一次性验证码登录', timeout=3) + if not otp_btn: + otp_btn = wait_for_element(page, 'text=Log in with a one-time code', timeout=3) + if otp_btn: + old_url = page.url + otp_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + except Exception: + pass + + # 检查密码框是否已有内容(避免重复输入) + password_input = wait_for_element(page, 'css:input[type="password"]', timeout=5) + if not password_input: + log.error("无法找到密码输入框") + return False + + # 检查是否已输入密码 + try: + current_value = password_input.attr('value') or '' + if len(current_value) > 0: + log.info("密码已输入,点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + except Exception: + pass + + log.step("等待密码输入框...") + human_delay() # 模拟人类思考时间 + log.step("输入密码...") + type_slowly(page, 'css:input[type="password"]', password) + log.success("密码已输入") + + # 点击继续 + human_delay(0.5, 1.2) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + # 等待页面变化,检测是否密码错误 + time.sleep(2) + + # 检查是否出现密码错误提示 + try: + error_text = page.ele('text:Incorrect email address or password', timeout=1) + if error_text and error_text.states.is_displayed: + log.warning("密码错误,尝试使用一次性验证码登录...") + otp_btn = wait_for_element(page, 'text=使用一次性验证码登录', timeout=3) + if not otp_btn: + otp_btn = wait_for_element(page, 'text=Log in with a one-time code', timeout=3) + if otp_btn: + otp_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + except Exception: + pass + + wait_for_url_change(page, old_url, timeout=10) + continue + + # 步骤3: 验证码页面 + if "auth.openai.com/email-verification" in current_url: + break # 跳出循环,进入验证码流程 + + # 步骤4: 姓名/年龄页面 (账号已存在) + if "auth.openai.com/about-you" in current_url: + break # 跳出循环,进入补充信息流程 + + # 处理错误 + if check_and_handle_error(page): + time.sleep(0.5) + continue + + # 短暂等待页面变化 + time.sleep(0.5) + + # === 根据 URL 快速判断页面状态 === + current_url = page.url + + # 如果是 chatgpt.com 首页,说明已注册成功 + if "chatgpt.com" in current_url and "auth.openai.com" not in current_url: + try: + if is_logged_in(page): + log.success("检测到已登录,账号已注册成功") + return True + except Exception: + pass + + # 检测到姓名/年龄输入页面 (账号已存在,只需补充信息) + if "auth.openai.com/about-you" in current_url: + log_current_url(page, "个人信息页面") + log.info("检测到姓名输入页面,账号已存在,补充信息...") + + # 等待页面加载 + name_input = wait_for_element(page, 'css:input[name="name"]', timeout=5) + if not name_input: + name_input = wait_for_element(page, 'css:input[autocomplete="name"]', timeout=3) + + # 输入姓名 + random_name = get_random_name() + log.step(f"输入姓名: {random_name}") + type_slowly(page, 'css:input[name="name"], input[autocomplete="name"]', random_name) + + # 输入生日 (与正常注册流程一致) + birthday = get_random_birthday() + log.step(f"输入生日: {birthday['year']}/{birthday['month']}/{birthday['day']}") + + # 年份 + year_input = wait_for_element(page, 'css:[data-type="year"]', timeout=10) + if year_input: + year_input.click() + time.sleep(0.15) + year_input.input(birthday['year'], clear=True) + time.sleep(0.2) + + # 月份 + month_input = wait_for_element(page, 'css:[data-type="month"]', timeout=5) + if month_input: + month_input.click() + time.sleep(0.15) + month_input.input(birthday['month'], clear=True) + time.sleep(0.2) + + # 日期 + day_input = wait_for_element(page, 'css:[data-type="day"]', timeout=5) + if day_input: + day_input.click() + time.sleep(0.15) + day_input.input(birthday['day'], clear=True) + + log.success("生日已输入") + + # 点击提交 + log.step("点击最终提交...") + time.sleep(0.5) + submit_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if submit_btn: + submit_btn.click() + + time.sleep(2) + log.success(f"注册完成: {email}") + return True + + # 检测到验证码页面 + needs_verification = "auth.openai.com/email-verification" in current_url + + if needs_verification: + log_current_url(page, "邮箱验证码页面") + + if not needs_verification: + # 检查验证码输入框是否存在 + code_input = wait_for_element(page, 'css:input[name="code"]', timeout=3) + if code_input: + needs_verification = True + log_current_url(page, "邮箱验证码页面") + + # 只有在 chatgpt.com 页面且已登录才能判断为成功 + if not needs_verification: + try: + if "chatgpt.com" in page.url and is_logged_in(page): + log.success("账号已注册成功") + return True + except Exception: + pass + log.error("注册流程异常,未到达预期页面") + return False + + # 获取验证码 + log.step("等待验证码邮件...") + verification_code, error, email_time = unified_get_verification_code(email) + + if not verification_code: + verification_code = input(" ⚠️ 请手动输入验证码: ").strip() + + if not verification_code: + log.error("无法获取验证码") + return False + + # 验证码重试循环 (最多重试 3 次) + max_code_retries = 3 + for code_attempt in range(max_code_retries): + # 输入验证码 + log.step(f"输入验证码: {verification_code}") + while check_and_handle_error(page): + time.sleep(1) + + # 重新获取输入框 (可能页面已刷新) + code_input = wait_for_element(page, 'css:input[name="code"]', timeout=10) + if not code_input: + code_input = wait_for_element(page, 'css:input[placeholder*="代码"]', timeout=5) + + if not code_input: + # 再次检查是否已登录 + try: + if is_logged_in(page): + log.success("检测到已登录,跳过验证码输入") + return True + except Exception: + pass + log.error("无法找到验证码输入框") + return False + + # 清空并输入验证码 + try: + code_input.clear() + except Exception: + pass + type_slowly(page, 'css:input[name="code"], input[placeholder*="代码"]', verification_code, base_delay=0.08) + time.sleep(0.5) + + # 点击继续 + log.step("点击继续...") + for attempt in range(3): + try: + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=10) + if continue_btn: + continue_btn.click() + break + except Exception: + time.sleep(0.5) + + time.sleep(2) + + # 检查是否出现"代码不正确"错误 + try: + error_text = page.ele('text:代码不正确', timeout=1) + if not error_text: + error_text = page.ele('text:incorrect', timeout=1) + if not error_text: + error_text = page.ele('text:Invalid code', timeout=1) + + if error_text and error_text.states.is_displayed: + if code_attempt < max_code_retries - 1: + log.warning(f"验证码错误,尝试重新获取 ({code_attempt + 1}/{max_code_retries})...") + + # 点击"重新发送电子邮件" + resend_btn = page.ele('text:重新发送电子邮件', timeout=3) + if not resend_btn: + resend_btn = page.ele('text:Resend email', timeout=2) + if not resend_btn: + resend_btn = page.ele('text:resend', timeout=2) + + if resend_btn: + resend_btn.click() + log.info("已点击重新发送,等待新验证码...") + time.sleep(3) + + # 重新获取验证码 + verification_code, error, email_time = unified_get_verification_code(email) + if not verification_code: + verification_code = input(" ⚠️ 请手动输入验证码: ").strip() + if verification_code: + continue # 继续下一次尝试 + + log.warning("无法重新发送验证码") + else: + log.error("验证码多次错误,放弃") + return False + else: + # 没有错误,验证码正确,跳出循环 + break + except Exception: + # 没有检测到错误,继续 + break + + while check_and_handle_error(page): + time.sleep(0.5) + + # 记录当前页面 (应该是 about-you 个人信息页面) + log_current_url(page, "验证码通过后-个人信息页面") + + # 输入姓名 (随机外国名字) + random_name = get_random_name() + log.step(f"输入姓名: {random_name}") + name_input = wait_for_element(page, 'css:input[name="name"]', timeout=15) + if not name_input: + name_input = wait_for_element(page, 'css:input[autocomplete="name"]', timeout=5) + type_slowly(page, 'css:input[name="name"], input[autocomplete="name"]', random_name) + + # 输入生日 (随机 2000-2005) + birthday = get_random_birthday() + log.step(f"输入生日: {birthday['year']}/{birthday['month']}/{birthday['day']}") + + # 年份 + year_input = wait_for_element(page, 'css:[data-type="year"]', timeout=10) + if year_input: + year_input.click() + time.sleep(0.15) + year_input.input(birthday['year'], clear=True) + time.sleep(0.2) + + # 月份 + month_input = wait_for_element(page, 'css:[data-type="month"]', timeout=5) + if month_input: + month_input.click() + time.sleep(0.15) + month_input.input(birthday['month'], clear=True) + time.sleep(0.2) + + # 日期 + day_input = wait_for_element(page, 'css:[data-type="day"]', timeout=5) + if day_input: + day_input.click() + time.sleep(0.15) + day_input.input(birthday['day'], clear=True) + + log.success("生日已输入") + + # 最终提交 + log.step("点击最终提交...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=10) + if continue_btn: + continue_btn.click() + + # 等待并检查是否出现 "email not supported" 错误 + time.sleep(2) + try: + error_text = page.ele('text:The email you provided is not supported', timeout=2) + if error_text and error_text.states.is_displayed: + log.error("邮箱域名不被支持,需要加入黑名单") + return "domain_blacklisted" + except Exception: + pass + + log.success(f"注册完成: {email}") + time.sleep(1) + return True + + except Exception as e: + log.error(f"注册失败: {e}") + return False + + +def perform_codex_authorization(page, email: str, password: str) -> dict: + """执行 Codex 授权流程 + + Args: + page: 浏览器实例 + email: 邮箱地址 + password: 密码 + + Returns: + dict: codex_data 或 None + """ + log.info(f"开始 Codex 授权: {email}", icon="code") + + # 生成授权 URL + auth_url, session_id = crs_generate_auth_url() + if not auth_url or not session_id: + log.error("无法获取授权 URL") + return None + + # 打开授权页面 + log.step("打开授权页面...") + log.info(f"[URL] 授权URL: {auth_url}", icon="browser") + page.get(auth_url) + wait_for_page_stable(page, timeout=5) + log_current_url(page, "授权页面加载完成", force=True) + + # 检测错误页面 + check_and_handle_error_page(page) + + try: + # 输入邮箱 + log.step("输入邮箱...") + + # 再次检测错误页面 + check_and_handle_error_page(page) + + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=10) + if not email_input: + # 可能是错误页面,再检测一次 + if check_and_handle_error_page(page): + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=5) + if not email_input: + email_input = wait_for_element(page, 'css:input[name="email"]', timeout=5) + if not email_input: + email_input = wait_for_element(page, '#email', timeout=5) + type_slowly(page, 'css:input[type="email"], input[name="email"], #email', email, base_delay=0.06) + + # 点击继续 + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "输入邮箱后点击继续") + + except Exception as e: + log.warning(f"邮箱输入步骤异常: {e}") + + log_current_url(page, "邮箱步骤完成后") + + # 检测错误页面 + if check_and_handle_error_page(page): + # 错误重试后,检查当前页面状态 + current_url = page.url + # 如果回到了登录页面,需要重新输入邮箱 + if "auth.openai.com/log-in" in current_url and "/password" not in current_url: + log.info("重试后回到登录页,重新输入邮箱...") + try: + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=5) + if email_input: + type_slowly(page, 'css:input[type="email"]', email, base_delay=0.06) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + except Exception as e: + log.warning(f"重新输入邮箱异常: {e}") + + # 再次检查当前 URL,确定下一步 + current_url = page.url + + # 只有在密码页面才输入密码 + if "/password" in current_url or "log-in/password" in current_url or "create-account/password" in current_url: + try: + # 输入密码 + log.step("输入密码...") + password_input = wait_for_element(page, 'css:input[type="password"]', timeout=10) + if not password_input: + # 可能是错误页面 + if check_and_handle_error_page(page): + password_input = wait_for_element(page, 'css:input[type="password"]', timeout=5) + if not password_input: + password_input = wait_for_element(page, 'css:input[name="password"]', timeout=5) + + if password_input: + type_slowly(page, 'css:input[type="password"], input[name="password"]', password, base_delay=0.06) + + # 点击继续 + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "输入密码后点击继续") + + except Exception as e: + log.warning(f"密码输入步骤异常: {e}") + else: + # 不在密码页面,可能需要先输入邮箱 + log.info(f"当前不在密码页面: {current_url}") + try: + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=3) + if email_input: + log.step("输入邮箱...") + type_slowly(page, 'css:input[type="email"]', email, base_delay=0.06) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + + # 现在应该在密码页面了 + password_input = wait_for_element(page, 'css:input[type="password"]', timeout=10) + if password_input: + log.step("输入密码...") + type_slowly(page, 'css:input[type="password"]', password, base_delay=0.06) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + except Exception as e: + log.warning(f"登录流程异常: {e}") + + log_current_url(page, "密码步骤完成后") + + # 等待授权回调 + max_wait = 45 # 减少等待时间 + start_time = time.time() + code = None + progress_shown = False + last_url_in_loop = None + log.step(f"等待授权回调 (最多 {max_wait}s)...") + + while time.time() - start_time < max_wait: + try: + current_url = page.url + + # 记录URL变化 + if current_url != last_url_in_loop: + log_current_url(page, "等待回调中") + last_url_in_loop = current_url + + # 检查是否到达回调页面 + if "localhost:1455/auth/callback" in current_url and "code=" in current_url: + if progress_shown: + log.progress_clear() + log.success("获取到回调 URL") + log.info(f"[URL] 回调地址: {current_url}", icon="browser") + code = extract_code_from_url(current_url) + if code: + log.success("提取授权码成功") + break + + # 尝试点击授权按钮 + try: + buttons = page.eles('css:button[type="submit"]') + for btn in buttons: + if btn.states.is_displayed and btn.states.is_enabled: + btn_text = btn.text.lower() + if any(x in btn_text for x in ['allow', 'authorize', 'continue', '授权', '允许', '继续', 'accept']): + if progress_shown: + log.progress_clear() + progress_shown = False + log.step(f"点击按钮: {btn.text}") + btn.click() + time.sleep(1.5) # 减少等待 + break + except Exception: + pass + + elapsed = int(time.time() - start_time) + log.progress_inline(f"[等待中... {elapsed}s]") + progress_shown = True + time.sleep(1.5) # 减少轮询间隔 + + except Exception as e: + if progress_shown: + log.progress_clear() + progress_shown = False + log.warning(f"检查异常: {e}") + time.sleep(1.5) + + if not code: + if progress_shown: + log.progress_clear() + log.warning("授权超时") + try: + current_url = page.url + if "code=" in current_url: + code = extract_code_from_url(current_url) + except Exception: + pass + + if not code: + log.error("无法获取授权码") + return None + + # 交换 tokens + log.step("交换 tokens...") + codex_data = crs_exchange_code(code, session_id) + + if codex_data: + log.success("Codex 授权成功") + return codex_data + else: + log.error("Token 交换失败") + return None + + +def perform_codex_authorization_with_otp(page, email: str) -> dict: + """执行 Codex 授权流程 (使用一次性验证码登录,适用于已注册的 Team Owner) + + Args: + page: 浏览器页面实例 + email: 邮箱地址 + + Returns: + dict: codex_data 或 None + """ + log.info("开始 Codex 授权 (OTP 登录)...", icon="auth") + + # 生成授权 URL + auth_url, session_id = crs_generate_auth_url() + if not auth_url or not session_id: + log.error("无法获取授权 URL") + return None + + # 打开授权页面 + log.step("打开授权页面...") + log.info(f"[URL] 授权URL: {auth_url}", icon="browser") + page.get(auth_url) + wait_for_page_stable(page, timeout=5) + log_current_url(page, "OTP授权页面加载完成", force=True) + + try: + # 输入邮箱 + log.step("输入邮箱...") + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=10) + if not email_input: + email_input = wait_for_element(page, 'css:input[name="email"]', timeout=5) + type_slowly(page, 'css:input[type="email"], input[name="email"], #email', email, base_delay=0.06) + + # 点击继续 + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "OTP流程-输入邮箱后") + + except Exception as e: + log.warning(f"邮箱输入步骤异常: {e}") + + log_current_url(page, "OTP流程-邮箱步骤完成后") + + try: + # 检查是否在密码页面,如果是则点击"使用一次性验证码登录" + current_url = page.url + if "/log-in/password" in current_url or "/password" in current_url: + log.step("检测到密码页面,点击使用一次性验证码登录...") + otp_btn = wait_for_element(page, 'text=使用一次性验证码登录', timeout=5) + if not otp_btn: + otp_btn = wait_for_element(page, 'text=Log in with a one-time code', timeout=3) + if not otp_btn: + # 尝试通过按钮文本查找 + buttons = page.eles('css:button') + for btn in buttons: + btn_text = btn.text.lower() + if '一次性验证码' in btn_text or 'one-time' in btn_text: + otp_btn = btn + break + + if otp_btn: + old_url = page.url + otp_btn.click() + log.success("已点击一次性验证码登录按钮") + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "点击OTP按钮后") + else: + log.warning("未找到一次性验证码登录按钮") + else: + # 不在密码页面,尝试直接找 OTP 按钮 + log.step("点击使用一次性验证码登录...") + otp_btn = wait_for_element(page, 'css:button[value="passwordless_login_send_otp"]', timeout=10) + if not otp_btn: + otp_btn = wait_for_element(page, 'css:button._inlinePasswordlessLogin', timeout=5) + if not otp_btn: + buttons = page.eles('css:button') + for btn in buttons: + if '一次性验证码' in btn.text or 'one-time' in btn.text.lower(): + otp_btn = btn + break + + if otp_btn: + otp_btn.click() + log.success("已点击一次性验证码登录按钮") + time.sleep(2) + else: + log.warning("未找到一次性验证码登录按钮,尝试继续...") + + except Exception as e: + log.warning(f"点击 OTP 按钮异常: {e}") + + log_current_url(page, "OTP流程-准备获取验证码") + + # 等待并获取验证码 + log.step("等待验证码邮件...") + verification_code, error, email_time = unified_get_verification_code(email) + + if not verification_code: + log.warning(f"自动获取验证码失败: {error}") + # 手动输入 + verification_code = input("⚠️ 请手动输入验证码: ").strip() + if not verification_code: + log.error("未输入验证码") + return None + + # 验证码重试循环 (最多重试 3 次) + max_code_retries = 3 + for code_attempt in range(max_code_retries): + try: + # 输入验证码 + log.step(f"输入验证码: {verification_code}") + code_input = wait_for_element(page, 'css:input[name="otp"]', timeout=10) + if not code_input: + code_input = wait_for_element(page, 'css:input[type="text"]', timeout=5) + if not code_input: + code_input = wait_for_element(page, 'css:input[autocomplete="one-time-code"]', timeout=5) + + if code_input: + # 清空并输入验证码 + try: + code_input.clear() + except Exception: + pass + type_slowly(page, 'css:input[name="otp"], input[type="text"], input[autocomplete="one-time-code"]', verification_code, base_delay=0.08) + log.success("验证码已输入") + else: + log.error("未找到验证码输入框") + return None + + # 点击继续/验证按钮 + log.step("点击继续...") + time.sleep(1) + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + time.sleep(2) + + # 检查是否出现"代码不正确"错误 + try: + error_text = page.ele('text:代码不正确', timeout=1) + if not error_text: + error_text = page.ele('text:incorrect', timeout=1) + if not error_text: + error_text = page.ele('text:Invalid code', timeout=1) + + if error_text and error_text.states.is_displayed: + if code_attempt < max_code_retries - 1: + log.warning(f"验证码错误,尝试重新获取 ({code_attempt + 1}/{max_code_retries})...") + + # 点击"重新发送电子邮件" + resend_btn = page.ele('text:重新发送电子邮件', timeout=3) + if not resend_btn: + resend_btn = page.ele('text:Resend email', timeout=2) + if not resend_btn: + resend_btn = page.ele('text:resend', timeout=2) + + if resend_btn: + resend_btn.click() + log.info("已点击重新发送,等待新验证码...") + time.sleep(3) + + # 重新获取验证码 + verification_code, error, email_time = unified_get_verification_code(email) + if not verification_code: + verification_code = input(" ⚠️ 请手动输入验证码: ").strip() + if verification_code: + continue # 继续下一次尝试 + + log.warning("无法重新发送验证码") + else: + log.error("验证码多次错误,放弃") + return None + else: + # 没有错误,验证码正确,跳出循环 + break + except Exception: + # 没有检测到错误元素,说明验证码正确,继续 + break + + except Exception as e: + log.warning(f"验证码输入步骤异常: {e}") + break + + # 等待授权回调 + max_wait = 45 + start_time = time.time() + code = None + progress_shown = False + last_url_in_loop = None + log.step(f"等待授权回调 (最多 {max_wait}s)...") + + while time.time() - start_time < max_wait: + try: + current_url = page.url + + # 记录URL变化 + if current_url != last_url_in_loop: + log_current_url(page, "OTP流程-等待回调中") + last_url_in_loop = current_url + + # 检查是否到达回调页面 + if "localhost:1455/auth/callback" in current_url and "code=" in current_url: + if progress_shown: + log.progress_clear() + log.success("获取到回调 URL") + log.info(f"[URL] 回调地址: {current_url}", icon="browser") + code = extract_code_from_url(current_url) + if code: + log.success("提取授权码成功") + break + + # 尝试点击授权按钮 + try: + buttons = page.eles('css:button[type="submit"]') + for btn in buttons: + if btn.states.is_displayed and btn.states.is_enabled: + btn_text = btn.text.lower() + if any(x in btn_text for x in ['allow', 'authorize', 'continue', '授权', '允许', '继续', 'accept']): + if progress_shown: + log.progress_clear() + progress_shown = False + log.step(f"点击按钮: {btn.text}") + btn.click() + time.sleep(1.5) + break + except Exception: + pass + + elapsed = int(time.time() - start_time) + log.progress_inline(f"[等待中... {elapsed}s]") + progress_shown = True + time.sleep(1.5) + + except Exception as e: + if progress_shown: + log.progress_clear() + progress_shown = False + log.warning(f"检查异常: {e}") + time.sleep(1.5) + + if not code: + if progress_shown: + log.progress_clear() + log.warning("授权超时") + try: + current_url = page.url + if "code=" in current_url: + code = extract_code_from_url(current_url) + except Exception: + pass + + if not code: + log.error("无法获取授权码") + return None + + # 交换 tokens + log.step("交换 tokens...") + codex_data = crs_exchange_code(code, session_id) + + if codex_data: + log.success("Codex 授权成功 (OTP)") + return codex_data + else: + log.error("Token 交换失败") + return None + + +def login_and_authorize_with_otp(email: str) -> tuple[bool, dict]: + """Team Owner 专用: 使用一次性验证码登录并完成 Codex 授权 + + Args: + email: 邮箱地址 + + Returns: + tuple: (success, codex_data) + - CRS 模式: codex_data 包含 tokens + - CPA 模式: codex_data 为 None (后台自动处理) + """ + with browser_context_with_retry(max_browser_retries=2) as ctx: + for attempt in ctx.attempts(): + try: + # 根据配置选择授权方式 + if AUTH_PROVIDER == "cpa": + # CPA 模式: 使用 OTP 登录 + success = perform_cpa_authorization_with_otp(ctx.page, email) + if success: + return True, None # CPA 模式不返回 codex_data + else: + if attempt < ctx.max_retries - 1: + log.warning("CPA OTP 授权失败,准备重试...") + continue + return False, None + else: + # CRS 模式: 使用 OTP 登录 + codex_data = perform_codex_authorization_with_otp(ctx.page, email) + + if codex_data: + return True, codex_data + else: + if attempt < ctx.max_retries - 1: + log.warning("授权失败,准备重试...") + continue + return False, None + + except Exception as e: + ctx.handle_error(e) + if ctx.current_attempt >= ctx.max_retries - 1: + return False, None + + return False, None + + +def register_and_authorize(email: str, password: str) -> tuple: + """完整流程: 注册 OpenAI + Codex 授权 (带重试机制) + + Args: + email: 邮箱地址 + password: 密码 + + Returns: + tuple: (register_success, codex_data) + - register_success: True/False/"domain_blacklisted" + - CRS 模式: codex_data 包含 tokens + - CPA 模式: codex_data 为 None (后台自动处理) + """ + with browser_context_with_retry(max_browser_retries=2) as ctx: + for attempt in ctx.attempts(): + try: + # 注册 OpenAI + register_result = register_openai_account(ctx.page, email, password) + + # 检查是否是域名黑名单错误 + if register_result == "domain_blacklisted": + ctx.stop() + return "domain_blacklisted", None + + if not register_result: + if attempt < ctx.max_retries - 1: + log.warning("注册失败,准备重试...") + continue + return False, None + + # 短暂等待确保注册完成 + time.sleep(0.5) + + # 根据配置选择授权方式 + if AUTH_PROVIDER == "cpa": + # CPA 模式: 授权成功即完成,后台自动处理账号 + success = perform_cpa_authorization(ctx.page, email, password) + return True, None if success else (True, None) # 注册成功,授权可能失败 + else: + # CRS 模式: 需要 codex_data + codex_data = perform_codex_authorization(ctx.page, email, password) + return True, codex_data + + except Exception as e: + ctx.handle_error(e) + if ctx.current_attempt >= ctx.max_retries - 1: + return False, None + + return False, None + + +def authorize_only(email: str, password: str) -> tuple[bool, dict]: + """仅执行 Codex 授权 (适用于已注册但未授权的账号) + + Args: + email: 邮箱地址 + password: 密码 + + Returns: + tuple: (success, codex_data) + - CRS 模式: codex_data 包含 tokens + - CPA 模式: codex_data 为 None (后台自动处理) + """ + with browser_context_with_retry(max_browser_retries=2) as ctx: + for attempt in ctx.attempts(): + try: + # 根据配置选择授权方式 + if AUTH_PROVIDER == "cpa": + log.info("已注册账号,使用 CPA 进行 Codex 授权...", icon="auth") + success = perform_cpa_authorization(ctx.page, email, password) + if success: + return True, None # CPA 模式不返回 codex_data + else: + if attempt < ctx.max_retries - 1: + log.warning("CPA 授权失败,准备重试...") + continue + return False, None + else: + # CRS 模式 + log.info("已注册账号,直接进行 Codex 授权...", icon="auth") + codex_data = perform_codex_authorization(ctx.page, email, password) + + if codex_data: + return True, codex_data + else: + if attempt < ctx.max_retries - 1: + log.warning("授权失败,准备重试...") + continue + return False, None + + except Exception as e: + ctx.handle_error(e) + if ctx.current_attempt >= ctx.max_retries - 1: + return False, None + + return False, None + + +# ==================== CPA 授权函数 ==================== + +def perform_cpa_authorization(page, email: str, password: str) -> bool: + """执行 CPA 授权流程 (密码登录) + + 与 CRS 的关键差异: + - CRS 使用 session_id,CPA 使用 state + - CRS 直接交换 code 得到 tokens,CPA 提交整个回调 URL 然后轮询状态 + - CPA 授权成功后不需要手动添加账号,后台自动处理 + + Args: + page: 浏览器实例 + email: 邮箱地址 + password: 密码 + + Returns: + bool: 授权是否成功 + """ + log.info(f"开始 CPA 授权: {email}", icon="code") + + # 生成授权 URL + auth_url, state = cpa_generate_auth_url() + if not auth_url or not state: + log.error("无法获取 CPA 授权 URL") + return False + + # 打开授权页面 + log.step("打开 CPA 授权页面...") + log.info(f"[URL] CPA授权URL: {auth_url}", icon="browser") + page.get(auth_url) + wait_for_page_stable(page, timeout=5) + log_current_url(page, "CPA授权页面加载完成", force=True) + + # 检测错误页面 + check_and_handle_error_page(page) + + try: + # 输入邮箱 + log.step("输入邮箱...") + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=10) + if not email_input: + email_input = wait_for_element(page, 'css:input[name="email"]', timeout=5) + if email_input: + type_slowly(page, 'css:input[type="email"], input[name="email"]', email, base_delay=0.06) + + # 点击继续 + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "CPA-输入邮箱后点击继续") + except Exception as e: + log.warning(f"CPA 邮箱输入步骤异常: {e}") + + log_current_url(page, "CPA-邮箱步骤完成后") + + # 输入密码 + current_url = page.url + if "/password" in current_url: + try: + log.step("输入密码...") + password_input = wait_for_element(page, 'css:input[type="password"]', timeout=10) + + if password_input: + type_slowly(page, 'css:input[type="password"]', password, base_delay=0.06) + + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "CPA-输入密码后点击继续") + except Exception as e: + log.warning(f"CPA 密码输入步骤异常: {e}") + + log_current_url(page, "CPA-密码步骤完成后") + + # 等待授权回调 + max_wait = 45 + start_time = time.time() + callback_url = None + progress_shown = False + last_url_in_loop = None + log.step(f"等待 CPA 授权回调 (最多 {max_wait}s)...") + + while time.time() - start_time < max_wait: + try: + current_url = page.url + + # 记录 URL 变化 + if current_url != last_url_in_loop: + log_current_url(page, "CPA等待回调中") + last_url_in_loop = current_url + + # 检查是否到达回调页面 (CPA 使用 localhost:1455) + if is_cpa_callback_url(current_url): + if progress_shown: + log.progress_clear() + log.success("CPA 获取到回调 URL") + log.info(f"[URL] CPA回调地址: {current_url}", icon="browser") + callback_url = current_url + break + + # 尝试点击授权按钮 + try: + buttons = page.eles('css:button[type="submit"]') + for btn in buttons: + if btn.states.is_displayed and btn.states.is_enabled: + btn_text = btn.text.lower() + if any(x in btn_text for x in ['allow', 'authorize', 'continue', '授权', '允许', '继续', 'accept']): + if progress_shown: + log.progress_clear() + progress_shown = False + log.step(f"点击按钮: {btn.text}") + btn.click() + time.sleep(1.5) + break + except Exception: + pass + + elapsed = int(time.time() - start_time) + log.progress_inline(f"[CPA等待中... {elapsed}s]") + progress_shown = True + time.sleep(1.5) + + except Exception as e: + if progress_shown: + log.progress_clear() + progress_shown = False + log.warning(f"CPA检查异常: {e}") + time.sleep(1.5) + + if progress_shown: + log.progress_clear() + + if not callback_url: + log.error("CPA 无法获取回调 URL") + return False + + # CPA 特有流程: 提交回调 URL + log.step("提交 CPA 回调 URL...") + if not cpa_submit_callback(callback_url): + log.error("CPA 回调 URL 提交失败") + return False + + # CPA 特有流程: 轮询授权状态 + if cpa_poll_auth_status(state): + log.success("CPA Codex 授权成功") + return True + else: + log.error("CPA 授权状态检查失败") + return False + + +def perform_cpa_authorization_with_otp(page, email: str) -> bool: + """执行 CPA 授权流程 (使用一次性验证码登录) + + Args: + page: 浏览器页面实例 + email: 邮箱地址 + + Returns: + bool: 授权是否成功 + """ + log.info("开始 CPA 授权 (OTP 登录)...", icon="auth") + + # 生成授权 URL + auth_url, state = cpa_generate_auth_url() + if not auth_url or not state: + log.error("无法获取 CPA 授权 URL") + return False + + # 打开授权页面 + log.step("打开 CPA 授权页面...") + log.info(f"[URL] CPA授权URL: {auth_url}", icon="browser") + page.get(auth_url) + wait_for_page_stable(page, timeout=5) + log_current_url(page, "CPA-OTP授权页面加载完成", force=True) + + try: + # 输入邮箱 + log.step("输入邮箱...") + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=10) + if not email_input: + email_input = wait_for_element(page, 'css:input[name="email"]', timeout=5) + type_slowly(page, 'css:input[type="email"], input[name="email"], #email', email, base_delay=0.06) + + # 点击继续 + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "CPA-OTP流程-输入邮箱后") + + except Exception as e: + log.warning(f"CPA OTP 邮箱输入步骤异常: {e}") + + log_current_url(page, "CPA-OTP流程-邮箱步骤完成后") + + try: + # 检查是否在密码页面,如果是则点击"使用一次性验证码登录" + current_url = page.url + if "/log-in/password" in current_url or "/password" in current_url: + log.step("检测到密码页面,点击使用一次性验证码登录...") + otp_btn = wait_for_element(page, 'text=使用一次性验证码登录', timeout=5) + if not otp_btn: + otp_btn = wait_for_element(page, 'text=Log in with a one-time code', timeout=3) + if not otp_btn: + buttons = page.eles('css:button') + for btn in buttons: + btn_text = btn.text.lower() + if '一次性验证码' in btn_text or 'one-time' in btn_text: + otp_btn = btn + break + + if otp_btn: + old_url = page.url + otp_btn.click() + log.success("已点击一次性验证码登录按钮") + wait_for_url_change(page, old_url, timeout=8) + log_url_change(page, old_url, "CPA-点击OTP按钮后") + else: + log.warning("未找到一次性验证码登录按钮") + + except Exception as e: + log.warning(f"CPA 点击 OTP 按钮异常: {e}") + + log_current_url(page, "CPA-OTP流程-准备获取验证码") + + # 等待并获取验证码 + log.step("等待验证码邮件...") + verification_code, error, email_time = unified_get_verification_code(email) + + if not verification_code: + log.warning(f"自动获取验证码失败: {error}") + verification_code = input(" 请手动输入验证码: ").strip() + if not verification_code: + log.error("未输入验证码") + return False + + # 验证码重试循环 + max_code_retries = 3 + for code_attempt in range(max_code_retries): + try: + log.step(f"输入验证码: {verification_code}") + code_input = wait_for_element(page, 'css:input[name="otp"]', timeout=10) + if not code_input: + code_input = wait_for_element(page, 'css:input[type="text"]', timeout=5) + if not code_input: + code_input = wait_for_element(page, 'css:input[autocomplete="one-time-code"]', timeout=5) + + if code_input: + try: + code_input.clear() + except Exception: + pass + type_slowly(page, 'css:input[name="otp"], input[type="text"], input[autocomplete="one-time-code"]', verification_code, base_delay=0.08) + log.success("验证码已输入") + else: + log.error("未找到验证码输入框") + return False + + log.step("点击继续...") + time.sleep(1) + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + continue_btn.click() + time.sleep(2) + + # 检查验证码错误 + try: + error_text = page.ele('text:代码不正确', timeout=1) or \ + page.ele('text:incorrect', timeout=1) or \ + page.ele('text:Invalid code', timeout=1) + + if error_text and error_text.states.is_displayed: + if code_attempt < max_code_retries - 1: + log.warning(f"验证码错误,尝试重新获取 ({code_attempt + 1}/{max_code_retries})...") + resend_btn = page.ele('text:重新发送电子邮件', timeout=3) or \ + page.ele('text:Resend email', timeout=2) or \ + page.ele('text:resend', timeout=2) + if resend_btn: + resend_btn.click() + log.info("已点击重新发送,等待新验证码...") + time.sleep(3) + verification_code, error, email_time = unified_get_verification_code(email) + if not verification_code: + verification_code = input(" 请手动输入验证码: ").strip() + if verification_code: + continue + log.warning("无法重新发送验证码") + else: + log.error("验证码多次错误,放弃") + return False + else: + break + except Exception: + break + + except Exception as e: + log.warning(f"CPA OTP 验证码输入步骤异常: {e}") + break + + # 等待授权回调 + max_wait = 45 + start_time = time.time() + callback_url = None + progress_shown = False + last_url_in_loop = None + log.step(f"等待 CPA 授权回调 (最多 {max_wait}s)...") + + while time.time() - start_time < max_wait: + try: + current_url = page.url + + if current_url != last_url_in_loop: + log_current_url(page, "CPA-OTP流程-等待回调中") + last_url_in_loop = current_url + + if is_cpa_callback_url(current_url): + if progress_shown: + log.progress_clear() + log.success("CPA 获取到回调 URL") + log.info(f"[URL] CPA回调地址: {current_url}", icon="browser") + callback_url = current_url + break + + try: + buttons = page.eles('css:button[type="submit"]') + for btn in buttons: + if btn.states.is_displayed and btn.states.is_enabled: + btn_text = btn.text.lower() + if any(x in btn_text for x in ['allow', 'authorize', 'continue', '授权', '允许', '继续', 'accept']): + if progress_shown: + log.progress_clear() + progress_shown = False + log.step(f"点击按钮: {btn.text}") + btn.click() + time.sleep(1.5) + break + except Exception: + pass + + elapsed = int(time.time() - start_time) + log.progress_inline(f"[CPA-OTP等待中... {elapsed}s]") + progress_shown = True + time.sleep(1.5) + + except Exception as e: + if progress_shown: + log.progress_clear() + progress_shown = False + log.warning(f"CPA OTP 检查异常: {e}") + time.sleep(1.5) + + if progress_shown: + log.progress_clear() + + if not callback_url: + log.error("CPA OTP 无法获取回调 URL") + return False + + # CPA 特有流程: 提交回调 URL + log.step("提交 CPA 回调 URL...") + if not cpa_submit_callback(callback_url): + log.error("CPA 回调 URL 提交失败") + return False + + # CPA 特有流程: 轮询授权状态 + if cpa_poll_auth_status(state): + log.success("CPA Codex 授权成功 (OTP)") + return True + else: + log.error("CPA 授权状态检查失败") + return False + + +# ==================== 格式3专用: 登录获取 Session ==================== + +def login_and_get_session(page, email: str, password: str) -> dict: + """登录 ChatGPT 并获取 accessToken 和 account_id (格式3专用) + + 用于 team.json 格式3 (只有邮箱和密码,没有 token) 的 Team Owner + 登录后从 /api/auth/session 获取 token 和 account_id + + Args: + page: 浏览器页面实例 + email: 邮箱 + password: 密码 + + Returns: + dict: {"token": "...", "account_id": "..."} 或 None + """ + log.info(f"登录获取 Session: {email}", icon="account") + + try: + # 打开 ChatGPT 登录页 + url = "https://chatgpt.com" + log.step(f"打开 {url}") + page.get(url) + wait_for_page_stable(page, timeout=8) + log_current_url(page, "登录页面加载完成", force=True) + + # 检查是否已登录 + if is_logged_in(page): + log.info("已登录,直接获取 Session...") + return _fetch_session_data(page) + + # 点击登录按钮 + log.step("点击登录...") + login_btn = wait_for_element(page, 'css:[data-testid="login-button"]', timeout=5) + if not login_btn: + login_btn = wait_for_element(page, 'text:登录', timeout=3) + if not login_btn: + login_btn = wait_for_element(page, 'text:Log in', timeout=3) + + if login_btn: + old_url = page.url + login_btn.click() + # 等待页面变化 + for _ in range(6): + time.sleep(0.5) + if page.url != old_url: + log_url_change(page, old_url, "点击登录按钮") + break + # 检测弹窗中的邮箱输入框 + try: + email_input = page.ele('css:input[type="email"], input[name="email"]', timeout=1) + if email_input and email_input.states.is_displayed: + break + except Exception: + pass + + current_url = page.url + log_current_url(page, "登录按钮点击后") + + # 登录流程循环 + max_steps = 10 + for step in range(max_steps): + current_url = page.url + log_current_url(page, f"登录流程步骤 {step + 1}") + + # 检查是否已登录成功 + if "chatgpt.com" in current_url and "auth.openai.com" not in current_url: + if is_logged_in(page): + log.success("登录成功") + # 检查并选择工作空间 + _check_and_select_workspace(page) + time.sleep(1) + return _fetch_session_data(page) + + # 步骤1: 输入邮箱 + if "auth.openai.com/log-in-or-create-account" in current_url or \ + ("chatgpt.com" in current_url and "auth.openai.com" not in current_url): + email_input = wait_for_element(page, 'css:input[type="email"]', timeout=5) + if email_input: + log.step("输入邮箱...") + human_delay() + type_slowly(page, 'css:input[type="email"]', email) + log.success("邮箱已输入") + + human_delay(0.5, 1.0) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + + # 步骤2: 输入密码 + if "/password" in current_url: + password_input = wait_for_element(page, 'css:input[type="password"]', timeout=5) + if password_input: + # 检查是否已输入密码 + try: + current_value = password_input.attr('value') or '' + if len(current_value) > 0: + log.info("密码已输入,点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + except Exception: + pass + + log.step("输入密码...") + human_delay() + type_slowly(page, 'css:input[type="password"]', password) + log.success("密码已输入") + + human_delay(0.5, 1.0) + log.step("点击继续...") + continue_btn = wait_for_element(page, 'css:button[type="submit"]', timeout=5) + if continue_btn: + old_url = page.url + continue_btn.click() + wait_for_url_change(page, old_url, timeout=10) + continue + + # 处理错误 + if check_and_handle_error(page): + time.sleep(0.5) + continue + + # 检查是否出现工作空间选择页面 + if _check_and_select_workspace(page): + # 选择工作空间后继续 + time.sleep(1) + continue + + time.sleep(0.5) + + # 最终检查是否登录成功 + if is_logged_in(page): + # 再次检查工作空间选择 + _check_and_select_workspace(page) + time.sleep(1) + log.success("登录成功") + return _fetch_session_data(page) + + log.error("登录流程未完成") + return None + + except Exception as e: + log.error(f"登录失败: {e}") + return None + + +def _check_and_select_workspace(page) -> bool: + """检查并选择工作空间 + + 如果出现"启动工作空间"页面,点击第一个"打开"按钮 + + Returns: + bool: 是否处理了工作空间选择 + """ + try: + # 检查是否有"启动工作空间"文字 + workspace_text = page.ele('text:启动工作空间', timeout=2) + if not workspace_text: + workspace_text = page.ele('text:Launch workspace', timeout=1) + + if not workspace_text: + return False + + log.info("检测到工作空间选择页面") + + # 直接点击第一个"打开"按钮 + open_btn = page.ele('text:打开', timeout=2) + if not open_btn: + open_btn = page.ele('text:Open', timeout=1) + + if open_btn: + log.step("选择第一个工作空间...") + open_btn.click() + + # 等待页面加载完成 + wait_for_page_stable(page, timeout=10) + + # 检查是否进入了职业选择页面(说明工作空间选择成功) + if _is_job_selection_page(page): + log.success("已进入工作空间") + + return True + + log.warning("未找到打开按钮") + return False + + except Exception as e: + log.warning(f"检查工作空间异常: {e}") + return False + + +def _is_job_selection_page(page) -> bool: + """检查是否在职业选择页面 + + 出现"你从事哪种工作?"说明工作空间选择成功 + + Returns: + bool: 是否在职业选择页面 + """ + try: + job_text = page.ele('text:你从事哪种工作', timeout=2) + if not job_text: + job_text = page.ele('text:What kind of work do you do', timeout=1) + return bool(job_text) + except Exception: + return False + + +def _fetch_session_data(page) -> dict: + """访问 session API 页面获取 token 和 account_id + + Args: + page: 浏览器页面实例 + + Returns: + dict: {"token": "...", "account_id": "..."} 或 None + """ + try: + import json as json_module + + # 直接访问 session API 页面 + log.step("获取 Session 数据...") + page.get("https://chatgpt.com/api/auth/session") + time.sleep(1) + + # 获取页面内容(JSON) + body = page.ele('tag:body', timeout=5) + if not body: + log.error("无法获取页面内容") + return None + + text = body.text + if not text or text == '{}': + log.error("Session 数据为空") + return None + + data = json_module.loads(text) + token = data.get('accessToken') + user = data.get('user', {}) + account = data.get('account', {}) + account_id = account.get('id') if account else None + + if token: + log.success(f"获取 Session 成功: {user.get('email', 'unknown')}") + if account_id: + log.info(f" account_id: {account_id[:20]}...") + else: + log.warning(" account_id: 未获取到") + return { + "token": token, + "account_id": account_id or "" + } + else: + log.error("Session 中没有 token") + return None + + except Exception as e: + log.error(f"获取 Session 失败: {e}") + return None + + +def login_and_authorize_team_owner(email: str, password: str, proxy: dict = None) -> dict: + """格式3专用: 登录获取 token/account_id 并同时进行授权 + + Args: + email: 邮箱 + password: 密码 + proxy: 代理配置 (可选) + + Returns: + dict: { + "success": True/False, # 授权是否成功 + "token": "...", + "account_id": "...", + "authorized": True/False # 是否已授权 + } + """ + from config import format_proxy_url + + with browser_context_with_retry(max_browser_retries=2) as ctx: + for attempt in ctx.attempts(): + try: + page = ctx.page + + if proxy: + proxy_url = format_proxy_url(proxy) + if proxy_url: + log.info(f"使用代理: {proxy.get('host')}:{proxy.get('port')}") + + # 步骤1: 登录获取 Session + session_data = login_and_get_session(page, email, password) + if not session_data: + if attempt < ctx.max_retries - 1: + log.warning("登录失败,准备重试...") + continue + return {"success": False} + + token = session_data["token"] + account_id = session_data["account_id"] + + # 步骤2: 进行授权 + if AUTH_PROVIDER == "cpa": + success = perform_cpa_authorization(page, email, password) + return { + "success": success, + "token": token, + "account_id": account_id, + "authorized": success + } + else: + codex_data = perform_codex_authorization(page, email, password) + if codex_data: + from crs_service import crs_add_account + crs_result = crs_add_account(email, codex_data) + return { + "success": bool(crs_result), + "token": token, + "account_id": account_id, + "authorized": bool(crs_result), + "crs_id": crs_result.get("id") if crs_result else None + } + else: + if attempt < ctx.max_retries - 1: + log.warning("授权失败,准备重试...") + continue + return { + "success": False, + "token": token, + "account_id": account_id, + "authorized": False + } + + except Exception as e: + ctx.handle_error(e) + if ctx.current_attempt >= ctx.max_retries - 1: + return {"success": False} + + return {"success": False} diff --git a/config.py b/config.py new file mode 100644 index 0000000..06a72bb --- /dev/null +++ b/config.py @@ -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), {}) diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..5721ab9 --- /dev/null +++ b/config.toml.example @@ -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 diff --git a/cpa_service.py b/cpa_service.py new file mode 100644 index 0000000..a616257 --- /dev/null +++ b/cpa_service.py @@ -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= + + 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 diff --git a/crs_service.py b/crs_service.py new file mode 100644 index 0000000..ddd1049 --- /dev/null +++ b/crs_service.py @@ -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 diff --git a/email_service.py b/email_service.py new file mode 100644 index 0000000..3d4f410 --- /dev/null +++ b/email_service.py @@ -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) diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..00d86b1 --- /dev/null +++ b/logger.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..591f09d --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..898208d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..5f72581 --- /dev/null +++ b/run.py @@ -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) diff --git a/s2a_service.py b/s2a_service.py new file mode 100644 index 0000000..621c223 --- /dev/null +++ b/s2a_service.py @@ -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 = [ + "S2A Dashboard", + "", + "Accounts", + f" Total: {total_accounts} | Normal: {normal_accounts}", + f" Error: {error_accounts} | RateLimit: {ratelimit_accounts}", + "", + "Today", + 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)}", + "", + "Total", + f" Requests: {fmt_num(total_requests)}", + f" Tokens: {fmt_tokens(total_tokens)}", + f" Cost: ${fmt_num(total_cost)}", + "", + "Realtime", + 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 diff --git a/team.json.example b/team.json.example new file mode 100644 index 0000000..0f9ac9e --- /dev/null +++ b/team.json.example @@ -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" + } +] diff --git a/team_service.py b/team_service.py new file mode 100644 index 0000000..fefe7e8 --- /dev/null +++ b/team_service.py @@ -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("无法获取状态信息") diff --git a/telegram_bot.py b/telegram_bot.py new file mode 100644 index 0000000..13685aa --- /dev/null +++ b/telegram_bot.py @@ -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("Bot Started\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 = """OpenAI Team Provisioner Bot + +Commands: +/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 + +Upload Accounts: +Send a JSON file or use /import with JSON data: +[{"account":"email","password":"pwd","token":"jwt"},...] +Then use /run to process them. + +Examples: +/run 0 - Process first team +/team 1 - View second team status +/logs 20 - 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 = ["Teams Status\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}] {team_name}: {completed}/{total} " + f"(F:{failed} P:{pending})" + ) + + # 当前任务状态 + if self.current_task and not self.current_task.done(): + lines.append(f"\nRunning: {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 \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"Team {team_idx}: {team_name}\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 \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"{log_text}", 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"{status_icon}" + elif health_pct >= 80: + status_icon = "OK" + status_line = f"{status_icon}" + elif health_pct >= 50: + status_icon = "WARN" + status_line = f"{status_icon}" + else: + status_icon = "CRITICAL" + status_line = f"{status_icon}" + + title = "LOW STOCK ALERT" if is_alert else "Account Stock" + + lines = [ + f"{title}", + "", + f"Status: {status_line}", + f"Health: {health_pct:.1f}%", + "", + f"Normal: {normal}", + 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( + "Upload Accounts to team.json\n\n" + "Usage:\n" + "1. Send a JSON file directly\n" + "2. /import followed by JSON data\n\n" + "JSON format:\n" + "[{\"account\":\"email\",\"password\":\"pwd\",\"token\":\"jwt\"},...]\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"Upload Complete\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()) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..f23a365 --- /dev/null +++ b/utils.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ecd8db0 --- /dev/null +++ b/uv.lock @@ -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" }, +]