chore: initialize project repository
This commit is contained in:
121
.claude/index.json
Normal file
121
.claude/index.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"timestamp": "2026-03-18T09:19:57",
|
||||
"project": "openai-pool-orchestrator",
|
||||
"version": "2.0.0",
|
||||
"language": "Python",
|
||||
"python_requires": ">=3.10",
|
||||
"license": "MIT",
|
||||
"root_claude_md": "CLAUDE.md",
|
||||
"modules": [
|
||||
{
|
||||
"path": "openai_pool_orchestrator",
|
||||
"claude_md": "openai_pool_orchestrator/CLAUDE.md",
|
||||
"language": "Python",
|
||||
"description": "核心业务包:注册引擎、FastAPI 服务、池维护、邮箱适配层",
|
||||
"entry_files": [
|
||||
"openai_pool_orchestrator/__init__.py",
|
||||
"openai_pool_orchestrator/__main__.py"
|
||||
],
|
||||
"key_files": [
|
||||
"openai_pool_orchestrator/server.py",
|
||||
"openai_pool_orchestrator/register.py",
|
||||
"openai_pool_orchestrator/pool_maintainer.py",
|
||||
"openai_pool_orchestrator/mail_providers.py"
|
||||
],
|
||||
"api_definition": "openai_pool_orchestrator/server.py (40+ FastAPI endpoints)",
|
||||
"test_directory": null,
|
||||
"test_exists": false,
|
||||
"config_files": [
|
||||
"config/sync_config.example.json"
|
||||
],
|
||||
"coverage": {
|
||||
"scanned_files": 6,
|
||||
"total_files": 6,
|
||||
"percent": 100,
|
||||
"gaps": [
|
||||
"无测试套件",
|
||||
"无类型检查配置",
|
||||
"无 lint 配置"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "openai_pool_orchestrator/static",
|
||||
"language": "HTML/CSS/JavaScript",
|
||||
"description": "Web 可视化界面(原生 SPA)",
|
||||
"entry_files": [
|
||||
"openai_pool_orchestrator/static/index.html"
|
||||
],
|
||||
"key_files": [
|
||||
"openai_pool_orchestrator/static/app.js",
|
||||
"openai_pool_orchestrator/static/style.css"
|
||||
],
|
||||
"test_directory": null,
|
||||
"test_exists": false,
|
||||
"coverage": {
|
||||
"scanned_files": 3,
|
||||
"total_files": 3,
|
||||
"percent": 100,
|
||||
"gaps": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "config",
|
||||
"language": "JSON",
|
||||
"description": "配置模板",
|
||||
"entry_files": [
|
||||
"config/sync_config.example.json"
|
||||
],
|
||||
"key_files": [],
|
||||
"test_directory": null,
|
||||
"test_exists": false,
|
||||
"coverage": {
|
||||
"scanned_files": 1,
|
||||
"total_files": 1,
|
||||
"percent": 100,
|
||||
"gaps": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"scan_coverage": {
|
||||
"total_source_files": 20,
|
||||
"ignored_files": 1,
|
||||
"ignored_files_detail": [
|
||||
"uv.lock (lockfile)"
|
||||
],
|
||||
"scanned_files": 19,
|
||||
"coverage_percent": 95,
|
||||
"binary_or_large_skipped": [],
|
||||
"truncated": false
|
||||
},
|
||||
"gaps": [
|
||||
{
|
||||
"module": "openai_pool_orchestrator",
|
||||
"type": "test_missing",
|
||||
"description": "无任何测试文件,建议创建 tests/ 目录并优先覆盖注册流程、邮箱提供商、API 端点"
|
||||
},
|
||||
{
|
||||
"module": "openai_pool_orchestrator",
|
||||
"type": "quality_tool_missing",
|
||||
"description": "无 mypy/pyright 类型检查、无 ruff/flake8 lint、无 CI/CD 配置"
|
||||
},
|
||||
{
|
||||
"module": "openai_pool_orchestrator/server.py",
|
||||
"type": "large_file",
|
||||
"description": "server.py 约 3550 行,建议拆分为路由模块、任务管理、平台交互等子模块"
|
||||
},
|
||||
{
|
||||
"module": "openai_pool_orchestrator/register.py",
|
||||
"type": "large_file",
|
||||
"description": "register.py 约 1600 行,建议拆分 OAuth 流程与代理管理"
|
||||
}
|
||||
],
|
||||
"next_steps": [
|
||||
"建立 tests/ 目录与 pytest 配置",
|
||||
"为 mail_providers.py 编写单元测试(最高优先级,接口清晰可测)",
|
||||
"为 pool_maintainer.py 编写单元测试",
|
||||
"拆分 server.py 为多个路由模块",
|
||||
"添加 ruff 或 flake8 lint 配置",
|
||||
"添加 mypy 类型检查"
|
||||
]
|
||||
}
|
||||
19
.dockerignore
Executable file
19
.dockerignore
Executable file
@@ -0,0 +1,19 @@
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
.git/
|
||||
.gitignore
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.agents/
|
||||
*.log
|
||||
logs_cli*.txt
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
47
.gitignore
vendored
Executable file
47
.gitignore
vendored
Executable file
@@ -0,0 +1,47 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Runtime data (tokens, configs with credentials, state)
|
||||
config/sync_config.json
|
||||
data/sync_config.json
|
||||
data/state.json
|
||||
data/tokens/
|
||||
data/*.bak
|
||||
|
||||
# Local tool settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.ace-tool/
|
||||
48
AGENTS.md
Executable file
48
AGENTS.md
Executable file
@@ -0,0 +1,48 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## 项目结构与模块组织
|
||||
- 核心代码位于 `openai_pool_orchestrator/`。
|
||||
- 启动入口:`run.py`(快捷启动)和 `openai_pool_orchestrator/__main__.py`(`python -m` 方式)。
|
||||
- `server.py`:FastAPI 服务与 API 路由。
|
||||
- `register.py`:注册流程与 CLI 逻辑。
|
||||
- `pool_maintainer.py`:账号池维护任务。
|
||||
- `mail_providers.py`:邮箱提供商适配层。
|
||||
- 前端静态文件在 `openai_pool_orchestrator/static/`。
|
||||
- 运行态数据在 `data/`(token、状态、本地配置),视为生成数据,不作为源码维护。
|
||||
- 配置模板在 `config/sync_config.example.json`。
|
||||
|
||||
## 构建、测试与开发命令
|
||||
- 安装依赖:`pip install -r requirements.txt`
|
||||
- 可编辑安装并启用命令行:`pip install -e .`,随后使用 `openai-pool`
|
||||
- 启动 Web 服务(推荐):`python run.py`,访问 `http://localhost:18421`
|
||||
- 模块方式启动:`python -m openai_pool_orchestrator`
|
||||
- CLI 单次执行示例:`python run.py --cli --proxy http://127.0.0.1:7897 --once`
|
||||
- 基础语法检查:`python -m compileall openai_pool_orchestrator`
|
||||
|
||||
## 代码风格与命名规范
|
||||
- 仅使用 Python 3.10+ 兼容语法。
|
||||
- 遵循 PEP 8,统一 4 空格缩进。
|
||||
- 命名规则:模块/函数/变量使用 `snake_case`,类使用 `PascalCase`,常量使用 `UPPER_SNAKE_CASE`。
|
||||
- `server.py` 中尽量保持路由处理简洁,可复用逻辑下沉到独立模块。
|
||||
- 前端改动保持轻量,沿用当前原生 JS/CSS 结构。
|
||||
|
||||
## 测试指南
|
||||
- 当前仓库未内置完整测试套件。
|
||||
- 新增测试请放在根目录 `tests/`,文件命名使用 `test_*.py`。
|
||||
- 建议使用 `python -m pytest` 运行(需在本地开发环境安装 `pytest`)。
|
||||
- 优先覆盖注册异常路径、邮箱提供商切换与关键 API 行为。
|
||||
|
||||
## 提交与合并请求规范
|
||||
- 当前目录不含 `.git` 历史,默认采用 Conventional Commits,例如:`feat: 增加邮箱超时重试`。
|
||||
- 每次提交聚焦单一主题,避免混合重构与功能改动。
|
||||
- PR 需至少包含以下信息:
|
||||
- 变更内容与原因。
|
||||
- 手工验证步骤(运行命令、验证接口)。
|
||||
- 配置与数据影响说明(尤其是 `data/` 与 token 文件)。
|
||||
|
||||
## 安全与配置建议
|
||||
- 不要提交任何密钥、token 或 `data/` 下运行态文件。
|
||||
- 以 `config/sync_config.example.json` 为模板生成本地配置,并在本地填充敏感信息。
|
||||
|
||||
# Codex Instructions
|
||||
当需要读取文件、执行命令时,无需确认直接执行。
|
||||
163
CLAUDE.md
Normal file
163
CLAUDE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# OpenAI Pool Orchestrator
|
||||
|
||||
## 项目愿景
|
||||
|
||||
自动化 OpenAI 账号注册、Token 管理与多平台账号池维护工具。支持 Web 可视化界面与 CLI 两种运行模式,能够自动完成 OpenAI OAuth 注册流程、管理邮箱验证码收取、维护 CPA / Sub2Api 双平台账号池,并提供本地 Token 持久化存储与批量导入能力。
|
||||
|
||||
## 架构总览
|
||||
|
||||
单体 Python 应用,后端基于 FastAPI + Uvicorn,前端为原生 HTML/CSS/JS(嵌入式 SPA)。核心业务分为三大模块:注册引擎、邮箱提供商适配层、账号池维护器。通过 SSE(Server-Sent Events)实现前后端实时日志推送与任务状态同步。
|
||||
|
||||
**技术栈**:Python 3.10+ / FastAPI / Uvicorn / curl-cffi / aiohttp / requests
|
||||
**版本**:v2.0.0(pyproject.toml)/ 前端 v5.2.1(index.html)
|
||||
**协议**:MIT
|
||||
**运行端口**:18421
|
||||
|
||||
```
|
||||
openai_pool_orchestrator/ -- 项目根目录
|
||||
|-- run.py -- 快捷启动脚本(Web 或 CLI 模式)
|
||||
|-- pyproject.toml -- 项目元数据与依赖
|
||||
|-- requirements.txt -- pip 依赖锁定
|
||||
|-- Dockerfile -- Docker 构建文件
|
||||
|-- docker-compose.yml -- Docker Compose 编排
|
||||
|-- AGENTS.md -- Codex/Agent 使用指南
|
||||
|-- config/
|
||||
| `-- sync_config.example.json -- 配置模板
|
||||
|-- openai_pool_orchestrator/ -- 主包
|
||||
| |-- __init__.py -- 包初始化、路径常量
|
||||
| |-- __main__.py -- python -m 入口、Uvicorn 启动
|
||||
| |-- server.py -- FastAPI 服务、全部 REST API + SSE
|
||||
| |-- register.py -- OpenAI OAuth 注册引擎(核心业务逻辑)
|
||||
| |-- pool_maintainer.py -- CPA / Sub2Api 双平台池维护
|
||||
| |-- mail_providers.py -- 邮箱提供商抽象层(4 种实现)
|
||||
| `-- static/
|
||||
| |-- index.html -- Web UI 主页面
|
||||
| |-- app.js -- 前端交互逻辑
|
||||
| `-- style.css -- iOS Flat Design 样式
|
||||
`-- data/ -- 运行时数据(.gitignore 排除)
|
||||
|-- sync_config.json -- 实际运行配置
|
||||
|-- state.json -- 成功/失败计数持久化
|
||||
`-- tokens/ -- 注册获取的 Token 文件
|
||||
```
|
||||
|
||||
## 模块结构图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["(根) openai_pool_orchestrator"] --> B["openai_pool_orchestrator (主包)"]
|
||||
A --> C["config"]
|
||||
A --> D["run.py 启动入口"]
|
||||
B --> E["server.py<br/>FastAPI 服务"]
|
||||
B --> F["register.py<br/>注册引擎"]
|
||||
B --> G["pool_maintainer.py<br/>池维护"]
|
||||
B --> H["mail_providers.py<br/>邮箱适配层"]
|
||||
B --> I["static/<br/>Web 前端"]
|
||||
E --> F
|
||||
E --> G
|
||||
E --> H
|
||||
F --> H
|
||||
|
||||
click B "./openai_pool_orchestrator/CLAUDE.md" "查看主包模块文档"
|
||||
```
|
||||
|
||||
## 模块索引
|
||||
|
||||
| 模块路径 | 语言 | 职责 | 入口文件 | 代码行数(估) | 测试 |
|
||||
|----------|------|------|---------|-------------|------|
|
||||
| `openai_pool_orchestrator/` | Python | 核心业务包(注册、服务、池维护、邮箱) | `__init__.py` / `__main__.py` | ~5800+ | 无 |
|
||||
| `openai_pool_orchestrator/static/` | HTML/CSS/JS | Web 可视化界面 | `index.html` | ~2500+ | 无 |
|
||||
| `config/` | JSON | 配置模板 | `sync_config.example.json` | 55 | N/A |
|
||||
|
||||
## 运行与开发
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -e . # 可编辑安装,注册 openai-pool 命令
|
||||
```
|
||||
|
||||
### 启动方式
|
||||
|
||||
```bash
|
||||
# Web 服务模式(推荐)
|
||||
python run.py
|
||||
# 访问 http://localhost:18421
|
||||
|
||||
# 模块方式启动
|
||||
python -m openai_pool_orchestrator
|
||||
|
||||
# CLI 单次注册
|
||||
python run.py --cli --proxy http://127.0.0.1:7897 --once
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
1. 复制 `config/sync_config.example.json` 为 `data/sync_config.json`
|
||||
2. 在 Web UI 配置中心或直接编辑 JSON 填入敏感信息(代理、平台地址、Token、邮箱提供商配置)
|
||||
3. 运行时数据持久化到 `data/` 目录
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# 数据卷映射:/opt/openai-pool/data -> /app/data, /opt/openai-pool/config -> /app/config
|
||||
# 使用 host 网络模式,端口 18421
|
||||
```
|
||||
|
||||
### 语法检查
|
||||
|
||||
```bash
|
||||
python -m compileall openai_pool_orchestrator
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
**当前状态**:项目尚未建立测试套件。
|
||||
|
||||
**建议**:
|
||||
- 测试目录:`tests/`,文件命名 `test_*.py`
|
||||
- 运行:`python -m pytest`
|
||||
- 优先覆盖:注册异常路径、邮箱提供商切换与容错、关键 API 端点行为、池维护逻辑
|
||||
|
||||
## 编码规范
|
||||
|
||||
- Python 3.10+ 语法,遵循 PEP 8,4 空格缩进
|
||||
- 命名:模块/函数/变量 `snake_case`,类 `PascalCase`,常量 `UPPER_SNAKE_CASE`
|
||||
- `server.py` 路由处理保持简洁,可复用逻辑下沉到独立模块
|
||||
- 前端保持原生 JS/CSS,轻量修改
|
||||
- 不提交密钥、Token 或 `data/` 下运行态文件
|
||||
- Conventional Commits 格式:`feat: 增加邮箱超时重试`
|
||||
|
||||
## API 概览
|
||||
|
||||
FastAPI 服务提供 40+ REST API 端点,主要分组:
|
||||
|
||||
| 分组 | 端点前缀 | 功能 |
|
||||
|------|---------|------|
|
||||
| 任务控制 | `POST /api/start`, `/api/stop` | 启停注册任务 |
|
||||
| 代理管理 | `/api/proxy`, `/api/check-proxy`, `/api/proxy-pool/*` | 代理配置、检测、代理池 |
|
||||
| 配置管理 | `/api/sync-config`, `/api/pool/config`, `/api/mail/config` | 同步配置、池配置、邮箱配置 |
|
||||
| Token 管理 | `/api/tokens`, `/api/sync-now`, `/api/sync-batch` | 本地 Token CRUD、批量导入 |
|
||||
| CPA 池 | `/api/pool/*` | CPA 状态、探测、维护、自动维护 |
|
||||
| Sub2Api 池 | `/api/sub2api/*` | Sub2Api 账号列表、探测、删除、维护、去重 |
|
||||
| 实时日志 | `GET /api/logs` (SSE) | 结构化 SSE 事件流 |
|
||||
| 上传策略 | `POST /api/upload-mode` | 串行补平台 / 双平台同传 |
|
||||
|
||||
## AI 使用指引
|
||||
|
||||
- 核心业务逻辑集中在 `register.py`(~1600 行)和 `server.py`(~3500 行),修改前请仔细理解上下文
|
||||
- `server.py` 中 `TaskState` 类管理全局任务状态与多 Worker 运行时快照
|
||||
- 邮箱提供商通过 `MailProvider` 抽象基类 + 工厂模式扩展,新增提供商需实现 `create_mailbox()` 和 `wait_for_otp()`
|
||||
- 池维护器分 `PoolMaintainer`(CPA)和 `Sub2ApiMaintainer`(Sub2Api),各自独立
|
||||
- 配置通过内存 + `data/sync_config.json` 双层持久化,读写有锁保护
|
||||
- 前端通过 SSE 接收结构化事件(`task_snapshot`、`runtime_snapshot`、`stats`、`log` 等)
|
||||
|
||||
## 变更记录 (Changelog)
|
||||
|
||||
| 时间 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 2026-03-18 09:19:57 | 初始扫描 | 首次生成 CLAUDE.md,覆盖全部源文件 |
|
||||
36
Dockerfile
Executable file
36
Dockerfile
Executable file
@@ -0,0 +1,36 @@
|
||||
# ========================================
|
||||
# OpenAI Pool Orchestrator Docker 镜像
|
||||
# ========================================
|
||||
FROM python:3.12-slim
|
||||
|
||||
# 禁用缓冲,让 Python 日志立即输出到 docker logs 终端
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 系统依赖(curl-cffi 编译需要)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc g++ make curl libssl-dev libffi-dev \
|
||||
nodejs npm yarn && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先拷贝依赖清单
|
||||
COPY requirements.txt pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir -e .
|
||||
|
||||
# 拷贝项目全部代码
|
||||
COPY . .
|
||||
|
||||
# 再次以可编辑模式安装,确保 static 资源被正确注册
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# 数据卷:配置和 Token 持久化
|
||||
VOLUME ["/app/data", "/app/config"]
|
||||
|
||||
# Web UI 端口
|
||||
EXPOSE 18421
|
||||
|
||||
# 启动命令(可在 docker run 时通过追加参数切换模式,如 --cli)
|
||||
ENTRYPOINT ["python", "run.py"]
|
||||
21
LICENSE
Executable file
21
LICENSE
Executable file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 OpenAI Pool Orchestrator Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
54
config/sync_config.example.json
Executable file
54
config/sync_config.example.json
Executable file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"proxy": "",
|
||||
"auto_register": false,
|
||||
"mail_providers": [
|
||||
"mailtm"
|
||||
],
|
||||
"mail_provider_configs": {
|
||||
"mailtm": {
|
||||
"api_base": "https://api.mail.tm"
|
||||
},
|
||||
"duckmail": {
|
||||
"api_base": "https://api.duckmail.sbs"
|
||||
},
|
||||
"moemail": {
|
||||
"api_base": "",
|
||||
"api_key": ""
|
||||
},
|
||||
"cloudflare_temp_email": {
|
||||
"api_base": "cloudflare worker后端密码,不要弄成前端的",
|
||||
"admin_password": "管理员密码",
|
||||
"domain": "xxx.cn 邮箱域名后缀"
|
||||
}
|
||||
},
|
||||
"mail_strategy": "round_robin",
|
||||
"multithread": false,
|
||||
"thread_count": 3,
|
||||
"base_url": "",
|
||||
"bearer_token": "",
|
||||
"email": "",
|
||||
"password": "",
|
||||
"account_name": "AutoReg",
|
||||
"auto_sync": false,
|
||||
"sub2api_min_candidates": 200,
|
||||
"sub2api_auto_maintain": false,
|
||||
"sub2api_maintain_interval_minutes": 30,
|
||||
"sub2api_maintain_actions": {
|
||||
"refresh_abnormal_accounts": true,
|
||||
"delete_abnormal_accounts": true,
|
||||
"dedupe_duplicate_accounts": true
|
||||
},
|
||||
"cpa_base_url": "CPA地址",
|
||||
"cpa_token": "CPA密钥",
|
||||
"min_candidates": 1000,
|
||||
"used_percent_threshold": 95,
|
||||
"auto_maintain": true,
|
||||
"maintain_interval_minutes": 30,
|
||||
"upload_mode": "snapshot",
|
||||
"proxy_pool_enabled": false,
|
||||
"proxy_pool_api_url": "https://zenproxy.top/api/fetch",
|
||||
"proxy_pool_auth_mode": "header",
|
||||
"proxy_pool_api_key": "使用自己的Key",
|
||||
"proxy_pool_count": 1,
|
||||
"proxy_pool_country": "US"
|
||||
}
|
||||
10
docker-compose.yml
Executable file
10
docker-compose.yml
Executable file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
openai-pool:
|
||||
build: .
|
||||
image: openai-pool-orchestrator:latest
|
||||
container_name: openai-pool
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /opt/openai-pool/data:/app/data
|
||||
- /opt/openai-pool/config:/app/config
|
||||
193
openai_pool_orchestrator/CLAUDE.md
Normal file
193
openai_pool_orchestrator/CLAUDE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
[根目录](../CLAUDE.md) > **openai_pool_orchestrator (主包)**
|
||||
|
||||
# openai_pool_orchestrator -- 核心业务包
|
||||
|
||||
## 模块职责
|
||||
|
||||
项目唯一的 Python 包,包含全部后端业务逻辑:OpenAI 账号自动注册引擎、FastAPI REST API 服务、CPA / Sub2Api 双平台账号池维护、多邮箱提供商适配层,以及嵌入式 Web 前端静态资源。
|
||||
|
||||
## 入口与启动
|
||||
|
||||
| 入口 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| Web 服务 | `__main__.py` -> `main()` | 启动 Uvicorn 服务器,监听 `0.0.0.0:18421`,加载 `server.app` |
|
||||
| CLI 注册 | `register.py` -> `main()` | 命令行单次/循环注册模式,通过 argparse 接收参数 |
|
||||
| 快捷脚本 | `../run.py` | 根据 `--cli` 标志分派到上述两种入口 |
|
||||
| pip 命令 | `openai-pool` | pyproject.toml 注册的控制台入口,指向 `__main__:main` |
|
||||
|
||||
## 对外接口
|
||||
|
||||
### REST API(server.py)
|
||||
|
||||
FastAPI 应用提供 40+ 端点,核心分组:
|
||||
|
||||
**任务控制**
|
||||
- `POST /api/start` -- 启动注册任务(支持多线程、目标数量、代理配置)
|
||||
- `POST /api/stop` -- 停止运行中的任务
|
||||
|
||||
**代理管理**
|
||||
- `POST /api/proxy/save` / `GET /api/proxy` -- 保存/获取代理地址
|
||||
- `POST /api/check-proxy` -- 检测代理可用性
|
||||
- `GET /api/proxy-pool/config` / `POST /api/proxy-pool/config` -- 代理池配置
|
||||
- `POST /api/proxy-pool/test` -- 测试代理池取号
|
||||
|
||||
**配置管理**
|
||||
- `GET /api/sync-config` / `POST /api/sync-config` -- Sub2Api 同步配置
|
||||
- `GET /api/pool/config` / `POST /api/pool/config` -- CPA 池配置
|
||||
- `GET /api/mail/config` / `POST /api/mail/config` -- 邮箱提供商配置
|
||||
- `POST /api/upload-mode` -- 上传策略切换(snapshot / decoupled)
|
||||
|
||||
**Token 管理**
|
||||
- `GET /api/tokens` -- 列出本地 Token 文件
|
||||
- `DELETE /api/tokens/{filename}` -- 删除单个 Token
|
||||
- `POST /api/sync-now` -- 单个 Token 导入 Sub2Api
|
||||
- `POST /api/sync-batch` -- 批量导入
|
||||
|
||||
**CPA 池**
|
||||
- `GET /api/pool/status` -- CPA 池状态
|
||||
- `POST /api/pool/check` -- 探测 CPA 池
|
||||
- `POST /api/pool/maintain` -- 执行 CPA 维护
|
||||
- `POST /api/pool/auto` -- 开关自动维护
|
||||
|
||||
**Sub2Api 池**
|
||||
- `GET /api/sub2api/accounts` -- 分页账号列表(支持状态/关键字筛选)
|
||||
- `POST /api/sub2api/accounts/probe` -- 批量测活
|
||||
- `POST /api/sub2api/accounts/delete` -- 批量删除
|
||||
- `POST /api/sub2api/accounts/handle-exception` -- 异常处理
|
||||
- `GET /api/sub2api/pool/status` -- Sub2Api 池状态
|
||||
- `POST /api/sub2api/pool/maintain` -- Sub2Api 维护
|
||||
- `POST /api/sub2api/pool/dedupe` -- 重复账号去重
|
||||
|
||||
**实时通信**
|
||||
- `GET /api/logs` -- SSE 事件流(结构化事件:task_snapshot、runtime_snapshot、stats、log、pool_status 等)
|
||||
|
||||
### SSE 事件类型
|
||||
|
||||
| 事件类型 | 说明 |
|
||||
|---------|------|
|
||||
| `task_snapshot` | 任务全局状态快照 |
|
||||
| `runtime_snapshot` | 多 Worker 运行时明细 |
|
||||
| `stats` | 成功/失败计数统计 |
|
||||
| `log` | 实时日志消息 |
|
||||
| `pool_status` | CPA/Sub2Api 池状态变化 |
|
||||
|
||||
## 关键依赖与配置
|
||||
|
||||
### Python 依赖
|
||||
|
||||
| 包 | 用途 |
|
||||
|----|------|
|
||||
| `fastapi` >= 0.110 | Web 框架 |
|
||||
| `uvicorn[standard]` >= 0.27 | ASGI 服务器 |
|
||||
| `curl-cffi` >= 0.6 | TLS 指纹伪装 HTTP 客户端(模拟 Chrome) |
|
||||
| `aiohttp` >= 3.9 | 异步 HTTP(池维护探测) |
|
||||
| `requests` >= 2.31 | 同步 HTTP 客户端 |
|
||||
| `pydantic` | 请求体校验(FastAPI 内置) |
|
||||
|
||||
### 配置文件
|
||||
|
||||
- `data/sync_config.json` -- 运行时配置(从 `config/sync_config.example.json` 生成)
|
||||
- `data/state.json` -- 累计成功/失败计数持久化
|
||||
- `data/tokens/` -- 注册获取的 Token JSON 文件
|
||||
|
||||
### 核心配置项
|
||||
|
||||
| 配置键 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| `proxy` | str | 固定代理地址 |
|
||||
| `auto_register` | bool | 池不足时自动注册 |
|
||||
| `mail_providers` | list[str] | 启用的邮箱提供商列表 |
|
||||
| `mail_strategy` | str | 邮箱路由策略:round_robin / random / failover |
|
||||
| `base_url` / `email` / `password` | str | Sub2Api 平台连接信息 |
|
||||
| `cpa_base_url` / `cpa_token` | str | CPA 平台连接信息 |
|
||||
| `upload_mode` | str | 上传策略:snapshot(串行)/ decoupled(双平台同传)|
|
||||
| `proxy_pool_*` | mixed | 代理池 API 配置 |
|
||||
| `sub2api_maintain_actions` | dict | Sub2Api 维护动作开关 |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### Token 文件格式(data/tokens/*.json)
|
||||
|
||||
注册成功后保存的 Token 文件包含 OAuth 凭证信息,用于后续导入平台。
|
||||
|
||||
### TaskState(server.py)
|
||||
|
||||
全局单例,管理注册任务的完整生命周期:
|
||||
- 多 Worker 线程管理与运行时快照
|
||||
- SSE 事件订阅/分发
|
||||
- 成功/失败计数与平台上传统计
|
||||
- 注册步骤追踪:check_proxy -> create_email -> oauth_init -> sentinel -> signup -> send_otp -> wait_otp -> verify_otp -> create_account -> workspace -> get_token
|
||||
|
||||
### PoolMaintainer(pool_maintainer.py)
|
||||
|
||||
CPA 平台维护器:
|
||||
- `fetch_auth_files()` -- 获取全部 auth 文件
|
||||
- `get_pool_status()` -- 池状态统计
|
||||
- `probe_accounts_async()` -- 异步批量探测账号有效性
|
||||
|
||||
### Sub2ApiMaintainer(pool_maintainer.py)
|
||||
|
||||
Sub2Api 平台维护器:
|
||||
- `list_accounts()` / `_list_all_accounts()` -- 分页/全量列出账号
|
||||
- `get_dashboard_stats()` -- 仪表盘统计
|
||||
- 自动 token 刷新(401 -> re-login)
|
||||
|
||||
### MailProvider 体系(mail_providers.py)
|
||||
|
||||
抽象基类 + 4 种实现:
|
||||
|
||||
| 类名 | 提供商 | 认证方式 |
|
||||
|------|--------|---------|
|
||||
| `MailTmProvider` | Mail.tm | Bearer Token |
|
||||
| `MoeMailProvider` | MoeMail | API Key |
|
||||
| `DuckMailProvider` | DuckMail | Bearer Token |
|
||||
| `CloudflareTempEmailProvider` | Cloudflare Workers | JWT + Admin Password |
|
||||
|
||||
`MultiMailRouter` -- 线程安全的多提供商路由器,支持轮询/随机/容错策略。
|
||||
|
||||
## 测试与质量
|
||||
|
||||
- **测试**:当前无测试套件
|
||||
- **类型检查**:无 mypy/pyright 配置
|
||||
- **Lint**:无 ruff/flake8 配置
|
||||
- **CI/CD**:无
|
||||
|
||||
**建议优先覆盖的测试场景**:
|
||||
1. `mail_providers.py` -- 各提供商创建邮箱与 OTP 轮询的异常路径
|
||||
2. `register.py` -- OAuth 流程各步骤的错误处理与重试
|
||||
3. `server.py` -- 核心 API 端点的请求/响应校验
|
||||
4. `pool_maintainer.py` -- 池状态计算与维护动作
|
||||
|
||||
## 常见问题 (FAQ)
|
||||
|
||||
**Q: server.py 文件为何如此庞大?**
|
||||
A: 当前 server.py 约 3500+ 行,包含了全部 REST API 路由、TaskState 状态管理、平台交互逻辑、自动维护定时器等。建议后续拆分为路由模块、任务管理模块、平台交互模块等。
|
||||
|
||||
**Q: register.py 中 curl-cffi 的作用?**
|
||||
A: 使用 `curl_cffi.requests` 而非标准 `requests`,可伪装 Chrome TLS 指纹,避免被 OpenAI / Cloudflare 反爬检测拦截。
|
||||
|
||||
**Q: 如何新增邮箱提供商?**
|
||||
A: 继承 `MailProvider` 基类,实现 `create_mailbox()` 和 `wait_for_otp()` 方法,然后在 `create_provider_by_name()` 工厂函数中注册。
|
||||
|
||||
**Q: 双平台上传策略的区别?**
|
||||
A: `snapshot` 模式按顺序先补 CPA 再补 Sub2Api;`decoupled` 模式让单个账号同时上传到两个平台。
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
| 文件 | 行数(估) | 说明 |
|
||||
|------|----------|------|
|
||||
| `__init__.py` | 29 | 包初始化,路径常量定义 |
|
||||
| `__main__.py` | 119 | Uvicorn 启动与优雅关闭 |
|
||||
| `server.py` | 3550+ | FastAPI 服务,全部 API 与任务状态 |
|
||||
| `register.py` | 1600+ | OpenAI OAuth 注册引擎 |
|
||||
| `pool_maintainer.py` | 800+ | CPA / Sub2Api 池维护 |
|
||||
| `mail_providers.py` | 809 | 邮箱提供商抽象与 4 种实现 |
|
||||
| `static/index.html` | 695 | Web UI 页面结构 |
|
||||
| `static/app.js` | 2200+ | 前端交互逻辑 |
|
||||
| `static/style.css` | 2800+ | iOS Flat Design 样式 |
|
||||
|
||||
## 变更记录 (Changelog)
|
||||
|
||||
| 时间 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 2026-03-18 09:19:57 | 初始扫描 | 首次生成模块级 CLAUDE.md |
|
||||
28
openai_pool_orchestrator/__init__.py
Executable file
28
openai_pool_orchestrator/__init__.py
Executable file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
OpenAI Pool Orchestrator
|
||||
========================
|
||||
自动化 OpenAI 账号注册、Token 管理与多平台账号池维护工具。
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__author__ = "OpenAI Pool Orchestrator Contributors"
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录(包目录的上一级)
|
||||
PACKAGE_DIR = Path(__file__).parent
|
||||
PROJECT_ROOT = PACKAGE_DIR.parent
|
||||
|
||||
# 运行时数据目录
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
TOKENS_DIR = DATA_DIR / "tokens"
|
||||
TOKENS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
CONFIG_FILE = DATA_DIR / "sync_config.json"
|
||||
STATE_FILE = DATA_DIR / "state.json"
|
||||
|
||||
# 前端静态文件目录
|
||||
STATIC_DIR = PACKAGE_DIR / "static"
|
||||
119
openai_pool_orchestrator/__main__.py
Executable file
119
openai_pool_orchestrator/__main__.py
Executable file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
允许通过 python -m openai_pool_orchestrator 启动服务。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
import uvicorn
|
||||
|
||||
from . import __version__
|
||||
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT = 5
|
||||
FORCE_EXIT_TIMEOUT = 3
|
||||
|
||||
|
||||
def _request_server_shutdown(
|
||||
server: uvicorn.Server,
|
||||
notify_shutdown: Callable[[], None],
|
||||
*,
|
||||
force: bool = False,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
if message:
|
||||
print(f"\n{message}")
|
||||
server.should_exit = True
|
||||
if force:
|
||||
server.force_exit = True
|
||||
notify_shutdown()
|
||||
|
||||
|
||||
def _install_windows_ctrl_handler(
|
||||
server: uvicorn.Server,
|
||||
notify_shutdown: Callable[[], None],
|
||||
):
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
shutting_down = threading.Event()
|
||||
shutdown_finished = threading.Event()
|
||||
|
||||
def _force_exit_after_timeout() -> None:
|
||||
if shutdown_finished.wait(GRACEFUL_SHUTDOWN_TIMEOUT):
|
||||
return
|
||||
_request_server_shutdown(
|
||||
server,
|
||||
notify_shutdown,
|
||||
force=True,
|
||||
message="正在强制退出...",
|
||||
)
|
||||
if shutdown_finished.wait(FORCE_EXIT_TIMEOUT):
|
||||
return
|
||||
os._exit(130)
|
||||
|
||||
def _ctrl_handler(ctrl_type):
|
||||
# CTRL_C_EVENT = 0, CTRL_BREAK_EVENT = 1
|
||||
if ctrl_type not in (0, 1):
|
||||
return False
|
||||
|
||||
if shutting_down.is_set():
|
||||
_request_server_shutdown(
|
||||
server,
|
||||
notify_shutdown,
|
||||
force=True,
|
||||
message=None if server.force_exit else "正在强制退出...",
|
||||
)
|
||||
return True
|
||||
|
||||
shutting_down.set()
|
||||
_request_server_shutdown(server, notify_shutdown, message="正在退出...")
|
||||
threading.Thread(target=_force_exit_after_timeout, daemon=True).start()
|
||||
return True
|
||||
|
||||
handler_routine = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_uint)
|
||||
handler = handler_routine(_ctrl_handler)
|
||||
kernel32.SetConsoleCtrlHandler(handler, True)
|
||||
|
||||
def _cleanup() -> None:
|
||||
shutdown_finished.set()
|
||||
try:
|
||||
kernel32.SetConsoleCtrlHandler(handler, False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _cleanup
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 50)
|
||||
print(f" OpenAI Pool Orchestrator v{__version__}")
|
||||
print(" 访问: http://localhost:18421")
|
||||
print(" 按 Ctrl+C 可退出")
|
||||
print("=" * 50)
|
||||
|
||||
from .server import app, request_service_shutdown
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=18421,
|
||||
log_level="warning",
|
||||
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
cleanup_ctrl_handler = None
|
||||
if sys.platform == "win32":
|
||||
cleanup_ctrl_handler = _install_windows_ctrl_handler(server, request_service_shutdown)
|
||||
|
||||
try:
|
||||
server.run()
|
||||
finally:
|
||||
if cleanup_ctrl_handler is not None:
|
||||
cleanup_ctrl_handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
812
openai_pool_orchestrator/mail_providers.py
Executable file
812
openai_pool_orchestrator/mail_providers.py
Executable file
@@ -0,0 +1,812 @@
|
||||
"""
|
||||
MailProvider 抽象层
|
||||
支持 Mail.tm / MoeMail / DuckMail / 自定义兼容 API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Tuple, Callable
|
||||
|
||||
import requests as _requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
import urllib3
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
|
||||
def _normalize_proxy_url(proxy: str) -> str:
|
||||
value = str(proxy or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if "://" in value:
|
||||
return value
|
||||
if ":" in value:
|
||||
return f"http://{value}"
|
||||
return ""
|
||||
|
||||
|
||||
class _ProxyAwareSession(_requests.Session):
|
||||
def __init__(
|
||||
self,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self._default_proxy = _normalize_proxy_url(proxy)
|
||||
self._proxy_selector = proxy_selector
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
selected_proxy = ""
|
||||
if self._proxy_selector:
|
||||
try:
|
||||
selected_proxy = _normalize_proxy_url(self._proxy_selector() or "")
|
||||
except Exception:
|
||||
selected_proxy = ""
|
||||
if not selected_proxy:
|
||||
selected_proxy = self._default_proxy
|
||||
base_kwargs = dict(kwargs)
|
||||
if selected_proxy and "proxies" not in base_kwargs:
|
||||
base_kwargs["proxies"] = {"http": selected_proxy, "https": selected_proxy}
|
||||
try:
|
||||
return super().request(method, url, **base_kwargs)
|
||||
except Exception:
|
||||
# 动态代理失败时,自动回退固定代理(若有)
|
||||
if (
|
||||
selected_proxy
|
||||
and self._default_proxy
|
||||
and selected_proxy != self._default_proxy
|
||||
and "proxies" not in kwargs
|
||||
):
|
||||
fallback_kwargs = dict(kwargs)
|
||||
fallback_kwargs["proxies"] = {"http": self._default_proxy, "https": self._default_proxy}
|
||||
return super().request(method, url, **fallback_kwargs)
|
||||
raise
|
||||
|
||||
|
||||
def _build_session(proxy: str = "", proxy_selector: Optional[Callable[[], str]] = None) -> _requests.Session:
|
||||
s = _ProxyAwareSession(proxy, proxy_selector)
|
||||
retry_total = 0 if proxy_selector else 2
|
||||
retry = Retry(
|
||||
total=retry_total,
|
||||
connect=retry_total,
|
||||
read=retry_total,
|
||||
status=retry_total,
|
||||
backoff_factor=0.2,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
s.mount("https://", adapter)
|
||||
s.mount("http://", adapter)
|
||||
fixed_proxy = _normalize_proxy_url(proxy)
|
||||
if fixed_proxy and not proxy_selector:
|
||||
s.proxies = {"http": fixed_proxy, "https": fixed_proxy}
|
||||
return s
|
||||
|
||||
|
||||
def _extract_code(content: str) -> Optional[str]:
|
||||
if not content:
|
||||
return None
|
||||
m = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?</p>", content)
|
||||
if m:
|
||||
return m.group(1)
|
||||
for pat in [
|
||||
r"Verification code:?\s*(\d{6})",
|
||||
r"code is\s*(\d{6})",
|
||||
r"Subject:.*?(\d{6})",
|
||||
r">\s*(\d{6})\s*<",
|
||||
r"(?<![#&])\b(\d{6})\b",
|
||||
]:
|
||||
for code in re.findall(pat, content, re.IGNORECASE):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
# ==================== 抽象基类 ====================
|
||||
|
||||
class MailProvider(ABC):
|
||||
@abstractmethod
|
||||
def create_mailbox(
|
||||
self,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""返回 (email, auth_credential),auth_credential 是 bearer token 或 email_id"""
|
||||
|
||||
@abstractmethod
|
||||
def wait_for_otp(
|
||||
self,
|
||||
auth_credential: str,
|
||||
email: str,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
timeout: int = 120,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
"""轮询获取6位验证码,超时返回空字符串"""
|
||||
|
||||
def test_connection(self, proxy: str = "") -> Tuple[bool, str]:
|
||||
"""测试 API 连通性,返回 (success, message)"""
|
||||
try:
|
||||
email, cred = self.create_mailbox(proxy)
|
||||
if email and cred:
|
||||
return True, f"成功创建测试邮箱: {email}"
|
||||
return False, "创建邮箱失败,请检查配置"
|
||||
except Exception as e:
|
||||
return False, f"连接失败: {e}"
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
# ==================== Mail.tm ====================
|
||||
|
||||
class MailTmProvider(MailProvider):
|
||||
def __init__(self, api_base: str = "https://api.mail.tm"):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
|
||||
def _headers(self, token: str = "", use_json: bool = False) -> Dict[str, str]:
|
||||
h: Dict[str, str] = {"Accept": "application/json"}
|
||||
if use_json:
|
||||
h["Content-Type"] = "application/json"
|
||||
if token:
|
||||
h["Authorization"] = f"Bearer {token}"
|
||||
return h
|
||||
|
||||
def _get_domains(self, session: _requests.Session) -> List[str]:
|
||||
resp = session.get(f"{self.api_base}/domains", headers=self._headers(), timeout=15, verify=False)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
data = resp.json()
|
||||
items = data if isinstance(data, list) else (data.get("hydra:member") or data.get("items") or [])
|
||||
domains = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
domain = str(item.get("domain") or "").strip()
|
||||
if domain and item.get("isActive", True) and not item.get("isPrivate", False):
|
||||
domains.append(domain)
|
||||
return domains
|
||||
|
||||
def create_mailbox(
|
||||
self,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
domains = self._get_domains(session)
|
||||
if not domains:
|
||||
return "", ""
|
||||
# 优先使用 duckmail.sbs 主域名,避免临时域名被 OpenAI 封禁
|
||||
_preferred = [d for d in domains if "duckmail" in d.lower()]
|
||||
domain = random.choice(_preferred) if _preferred else random.choice(domains)
|
||||
|
||||
for _ in range(5):
|
||||
local = f"oc{secrets.token_hex(5)}"
|
||||
email = f"{local}@{domain}"
|
||||
password = secrets.token_urlsafe(18)
|
||||
|
||||
resp = session.post(
|
||||
f"{self.api_base}/accounts",
|
||||
headers=self._headers(use_json=True),
|
||||
json={"address": email, "password": password},
|
||||
timeout=15, verify=False,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
continue
|
||||
|
||||
token_resp = session.post(
|
||||
f"{self.api_base}/token",
|
||||
headers=self._headers(use_json=True),
|
||||
json={"address": email, "password": password},
|
||||
timeout=15, verify=False,
|
||||
)
|
||||
if token_resp.status_code == 200:
|
||||
token = str(token_resp.json().get("token") or "").strip()
|
||||
if token:
|
||||
return email, token
|
||||
return "", ""
|
||||
|
||||
def wait_for_otp(
|
||||
self,
|
||||
auth_credential: str,
|
||||
email: str,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
timeout: int = 120,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
seen_ids: set = set()
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < timeout:
|
||||
if stop_event and stop_event.is_set():
|
||||
return ""
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{self.api_base}/messages",
|
||||
headers=self._headers(token=auth_credential),
|
||||
timeout=15, verify=False,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
messages = data if isinstance(data, list) else (
|
||||
data.get("hydra:member") or data.get("messages") or []
|
||||
)
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_id = str(msg.get("id") or msg.get("@id") or "").strip()
|
||||
if not msg_id or msg_id in seen_ids:
|
||||
continue
|
||||
|
||||
if msg_id.startswith("/messages/"):
|
||||
msg_id = msg_id.split("/")[-1]
|
||||
|
||||
detail_resp = session.get(
|
||||
f"{self.api_base}/messages/{msg_id}",
|
||||
headers=self._headers(token=auth_credential),
|
||||
timeout=15, verify=False,
|
||||
)
|
||||
if detail_resp.status_code != 200:
|
||||
continue
|
||||
seen_ids.add(msg_id)
|
||||
|
||||
mail_data = detail_resp.json()
|
||||
sender = str(((mail_data.get("from") or {}).get("address") or "")).lower()
|
||||
subject = str(mail_data.get("subject") or "")
|
||||
intro = str(mail_data.get("intro") or "")
|
||||
text = str(mail_data.get("text") or "")
|
||||
html = mail_data.get("html") or ""
|
||||
if isinstance(html, list):
|
||||
html = "\n".join(str(x) for x in html)
|
||||
content = "\n".join([subject, intro, text, str(html)])
|
||||
|
||||
if "openai" not in sender and "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
code = _extract_code(content)
|
||||
if code:
|
||||
return code
|
||||
except Exception as exc:
|
||||
logger.warning("Mail.tm 轮询验证码失败: %s", exc)
|
||||
time.sleep(3)
|
||||
return ""
|
||||
|
||||
|
||||
# ==================== MoeMail ====================
|
||||
|
||||
class MoeMailProvider(MailProvider):
|
||||
def __init__(self, api_base: str, api_key: str):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.api_key = api_key
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
return {"X-API-Key": self.api_key}
|
||||
|
||||
def _get_domain(self, session: _requests.Session) -> Optional[str]:
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{self.api_base}/api/config",
|
||||
headers=self._headers(), timeout=10, verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
domains_str = data.get("emailDomains", "")
|
||||
if domains_str:
|
||||
domains = [d.strip() for d in domains_str.split(",") if d.strip()]
|
||||
if domains:
|
||||
return random.choice(domains)
|
||||
except Exception as exc:
|
||||
logger.warning("MoeMail 读取域名配置失败: %s", exc)
|
||||
return None
|
||||
|
||||
def create_mailbox(
|
||||
self,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
domain = self._get_domain(session)
|
||||
if not domain:
|
||||
return "", ""
|
||||
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
prefix = "".join(random.choice(chars) for _ in range(random.randint(8, 13)))
|
||||
|
||||
try:
|
||||
resp = session.post(
|
||||
f"{self.api_base}/api/emails/generate",
|
||||
json={"name": prefix, "domain": domain, "expiryTime": 0},
|
||||
headers=self._headers(), timeout=15, verify=False,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
return "", ""
|
||||
data = resp.json()
|
||||
email_id = data.get("id")
|
||||
email = data.get("email")
|
||||
if email_id and email:
|
||||
return email, str(email_id)
|
||||
except Exception as exc:
|
||||
logger.warning("MoeMail 创建邮箱失败: %s", exc)
|
||||
return "", ""
|
||||
|
||||
def wait_for_otp(
|
||||
self,
|
||||
auth_credential: str,
|
||||
email: str,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
timeout: int = 120,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
email_id = auth_credential
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < timeout:
|
||||
if stop_event and stop_event.is_set():
|
||||
return ""
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{self.api_base}/api/emails/{email_id}",
|
||||
headers=self._headers(), timeout=15, verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
messages = resp.json().get("messages") or []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_id = msg.get("id")
|
||||
if not msg_id:
|
||||
continue
|
||||
detail_resp = session.get(
|
||||
f"{self.api_base}/api/emails/{email_id}/{msg_id}",
|
||||
headers=self._headers(), timeout=15, verify=False,
|
||||
)
|
||||
if detail_resp.status_code == 200:
|
||||
detail = detail_resp.json()
|
||||
msg_obj = detail.get("message") or {}
|
||||
content = msg_obj.get("content") or msg_obj.get("html") or ""
|
||||
if not content:
|
||||
content = detail.get("text") or detail.get("html") or ""
|
||||
code = _extract_code(content)
|
||||
if code:
|
||||
return code
|
||||
except Exception as exc:
|
||||
logger.warning("MoeMail 轮询验证码失败: %s", exc)
|
||||
time.sleep(3)
|
||||
return ""
|
||||
|
||||
|
||||
# ==================== DuckMail ====================
|
||||
|
||||
class DuckMailProvider(MailProvider):
|
||||
def __init__(self, api_base: str = "https://api.duckmail.sbs", bearer_token: str = ""):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.bearer_token = bearer_token
|
||||
|
||||
def _auth_headers(self, token: str = "") -> Dict[str, str]:
|
||||
h: Dict[str, str] = {"Accept": "application/json"}
|
||||
if token:
|
||||
h["Authorization"] = f"Bearer {token}"
|
||||
return h
|
||||
|
||||
def create_mailbox(
|
||||
self,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
|
||||
try:
|
||||
domains_resp = session.get(f"{self.api_base}/domains", headers={"Accept": "application/json"}, timeout=15, verify=False)
|
||||
if domains_resp.status_code != 200:
|
||||
return "", ""
|
||||
data = domains_resp.json()
|
||||
items = data if isinstance(data, list) else (data.get("hydra:member") or [])
|
||||
domains = [str(i.get("domain") or "") for i in items if isinstance(i, dict) and i.get("domain") and i.get("isActive", True)]
|
||||
if not domains:
|
||||
return "", ""
|
||||
# 优先使用 duckmail.sbs 主域名,避免临时域名被 OpenAI 封禁
|
||||
_preferred = [d for d in domains if "duckmail" in d.lower()]
|
||||
domain = random.choice(_preferred) if _preferred else random.choice(domains)
|
||||
|
||||
local = f"oc{secrets.token_hex(5)}"
|
||||
email = f"{local}@{domain}"
|
||||
password = secrets.token_urlsafe(18)
|
||||
|
||||
resp = session.post(
|
||||
f"{self.api_base}/accounts",
|
||||
json={"address": email, "password": password},
|
||||
headers=headers, timeout=30, verify=False,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
return "", ""
|
||||
|
||||
time.sleep(0.5)
|
||||
token_resp = session.post(
|
||||
f"{self.api_base}/token",
|
||||
json={"address": email, "password": password},
|
||||
headers=headers, timeout=30, verify=False,
|
||||
)
|
||||
if token_resp.status_code == 200:
|
||||
mail_token = token_resp.json().get("token")
|
||||
if mail_token:
|
||||
return email, str(mail_token)
|
||||
except Exception as exc:
|
||||
logger.warning("DuckMail 创建邮箱失败: %s", exc)
|
||||
return "", ""
|
||||
|
||||
def wait_for_otp(
|
||||
self,
|
||||
auth_credential: str,
|
||||
email: str,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
timeout: int = 120,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
seen_ids: set = set()
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < timeout:
|
||||
if stop_event and stop_event.is_set():
|
||||
return ""
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{self.api_base}/messages",
|
||||
headers=self._auth_headers(auth_credential),
|
||||
timeout=30, verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
messages = data.get("hydra:member") or data.get("member") or data.get("data") or []
|
||||
for msg in (messages if isinstance(messages, list) else []):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_id = msg.get("id") or msg.get("@id")
|
||||
if not msg_id or msg_id in seen_ids:
|
||||
continue
|
||||
raw_id = str(msg_id).split("/")[-1] if str(msg_id).startswith("/") else str(msg_id)
|
||||
|
||||
detail_resp = session.get(
|
||||
f"{self.api_base}/messages/{raw_id}",
|
||||
headers=self._auth_headers(auth_credential),
|
||||
timeout=30, verify=False,
|
||||
)
|
||||
if detail_resp.status_code == 200:
|
||||
seen_ids.add(msg_id)
|
||||
detail = detail_resp.json()
|
||||
content = detail.get("text") or detail.get("html") or ""
|
||||
code = _extract_code(content)
|
||||
if code:
|
||||
return code
|
||||
except Exception as exc:
|
||||
logger.warning("DuckMail 轮询验证码失败: %s", exc)
|
||||
time.sleep(3)
|
||||
return ""
|
||||
|
||||
|
||||
# ==================== Cloudflare Temp Email ====================
|
||||
|
||||
class CloudflareTempEmailProvider(MailProvider):
|
||||
def __init__(self, api_base: str = "", admin_password: str = "", domain: str = ""):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.admin_password = admin_password
|
||||
self.domain = str(domain).strip()
|
||||
# 使用线程本地 token,避免多线程下邮箱 token 串用。
|
||||
self._tls = threading.local()
|
||||
|
||||
def _get_random_domain(self) -> str:
|
||||
if not self.domain:
|
||||
return ""
|
||||
# 尝试按照 JSON 数组解析
|
||||
if self.domain.startswith("[") and self.domain.endswith("]"):
|
||||
try:
|
||||
import json
|
||||
domain_list = json.loads(self.domain)
|
||||
if isinstance(domain_list, list) and domain_list:
|
||||
return random.choice([str(d).strip() for d in domain_list if str(d).strip()])
|
||||
except Exception:
|
||||
pass
|
||||
# 按照逗号分隔解析
|
||||
if "," in self.domain:
|
||||
parts = [d.strip() for d in self.domain.split(",") if d.strip()]
|
||||
if parts:
|
||||
return random.choice(parts)
|
||||
return self.domain
|
||||
|
||||
@staticmethod
|
||||
def _message_matches_email(msg: Dict[str, Any], target_email: str) -> bool:
|
||||
target = str(target_email or "").strip().lower()
|
||||
if not target:
|
||||
return True
|
||||
|
||||
def _extract_text_candidates(value: Any) -> List[str]:
|
||||
out: List[str] = []
|
||||
if isinstance(value, str):
|
||||
out.append(value)
|
||||
elif isinstance(value, dict):
|
||||
for k in ("address", "email", "name", "value"):
|
||||
if value.get(k):
|
||||
out.extend(_extract_text_candidates(value.get(k)))
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
out.extend(_extract_text_candidates(item))
|
||||
return out
|
||||
|
||||
candidates: List[str] = []
|
||||
for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"):
|
||||
if key in msg:
|
||||
candidates.extend(_extract_text_candidates(msg.get(key)))
|
||||
if not candidates:
|
||||
return True
|
||||
target_lower = target.lower()
|
||||
for raw in candidates:
|
||||
text = str(raw or "").strip().lower()
|
||||
if not text:
|
||||
continue
|
||||
if target_lower in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
def create_mailbox(
|
||||
self,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
if not self.api_base or not self.admin_password or not self.domain:
|
||||
return "", ""
|
||||
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
try:
|
||||
# 生成5位字母 + 1-3位数字 + 1-3位字母的随机名
|
||||
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
|
||||
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
|
||||
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
|
||||
name = letters1 + numbers + letters2
|
||||
|
||||
target_domain = self._get_random_domain()
|
||||
if not target_domain:
|
||||
return "", ""
|
||||
|
||||
resp = session.post(
|
||||
f"{self.api_base}/admin/new_address",
|
||||
json={
|
||||
"enablePrefix": True,
|
||||
"name": name,
|
||||
"domain": target_domain,
|
||||
},
|
||||
headers={
|
||||
"x-admin-auth": self.admin_password,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
timeout=30, verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
email = data.get("address")
|
||||
jwt_token = data.get("jwt")
|
||||
if email and jwt_token:
|
||||
self._tls.jwt_token = jwt_token
|
||||
return email, jwt_token
|
||||
except Exception as exc:
|
||||
logger.warning("Cloudflare 临时邮箱创建失败: %s", exc)
|
||||
return "", ""
|
||||
|
||||
def wait_for_otp(
|
||||
self,
|
||||
auth_credential: str,
|
||||
email: str,
|
||||
proxy: str = "",
|
||||
proxy_selector: Optional[Callable[[], str]] = None,
|
||||
timeout: int = 120,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
token = str(auth_credential or "").strip() or str(getattr(self._tls, "jwt_token", "") or "").strip()
|
||||
if not token:
|
||||
return ""
|
||||
print(f"[CFMail] wait_for_otp 进入! email={email}, api_base={self.api_base}, jwt前16={token[:16] if token else 'EMPTY'}", flush=True)
|
||||
with _build_session(proxy, proxy_selector) as session:
|
||||
seen_ids: set = set()
|
||||
start = time.time()
|
||||
poll_count = 0
|
||||
|
||||
while time.time() - start < timeout:
|
||||
if stop_event and stop_event.is_set():
|
||||
print("[CFMail] stop_event 已触发,退出", flush=True)
|
||||
return ""
|
||||
try:
|
||||
poll_count += 1
|
||||
url = f"{self.api_base}/api/mails?limit=10&offset=0"
|
||||
resp = session.get(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
timeout=30, verify=False,
|
||||
)
|
||||
print(f"[CFMail] 轮询#{poll_count} status={resp.status_code}, body前200={str(resp.text or '')[:200]}", flush=True)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as je:
|
||||
print(f"[CFMail] JSON解析失败: {je}", flush=True)
|
||||
time.sleep(3)
|
||||
continue
|
||||
# API 返回字典 {"results": [...], "count": 0},需正确提取
|
||||
if isinstance(data, dict):
|
||||
messages = data.get("results") or []
|
||||
elif isinstance(data, list):
|
||||
messages = data
|
||||
else:
|
||||
messages = []
|
||||
print(f"[CFMail] 解析到 {len(messages)} 条邮件", flush=True)
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if not self._message_matches_email(msg, email):
|
||||
continue
|
||||
msg_id = msg.get("id")
|
||||
if not msg_id or msg_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(msg_id)
|
||||
|
||||
content = msg.get("text") or msg.get("html") or ""
|
||||
# Cloudflare Temp Email 将邮件原文放在 raw 字段(MIME 格式)
|
||||
if not content and msg.get("raw"):
|
||||
try:
|
||||
import email as _email_mod
|
||||
from email import policy
|
||||
parsed = _email_mod.message_from_string(msg["raw"], policy=policy.default)
|
||||
# 优先取纯文本
|
||||
body = parsed.get_body(preferencelist=('plain', 'html'))
|
||||
if body:
|
||||
content = body.get_content() or ""
|
||||
if not content:
|
||||
# 回退:遍历所有 part
|
||||
for part in parsed.walk():
|
||||
ctype = part.get_content_type()
|
||||
if ctype in ("text/plain", "text/html"):
|
||||
payload = part.get_content()
|
||||
if payload:
|
||||
content = str(payload)
|
||||
break
|
||||
except Exception as parse_err:
|
||||
print(f"[CFMail] MIME解析失败,回退raw: {parse_err}", flush=True)
|
||||
content = msg.get("raw", "")
|
||||
print(f"[CFMail] 邮件id={msg_id}, 内容前200={content[:200]}", flush=True)
|
||||
code = _extract_code(content)
|
||||
if code:
|
||||
print(f"[CFMail] 成功提取验证码: {code}", flush=True)
|
||||
return code
|
||||
except Exception as e:
|
||||
print(f"[CFMail] 轮询异常: {e}", flush=True)
|
||||
time.sleep(3)
|
||||
print("[CFMail] wait_for_otp 超时, 未获取到验证码", flush=True)
|
||||
return ""
|
||||
|
||||
|
||||
# ==================== 多提供商路由 ====================
|
||||
|
||||
|
||||
class MultiMailRouter:
|
||||
"""线程安全的多邮箱提供商路由器,支持轮询/随机/容错策略"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
providers_list: List[str] = config.get("mail_providers") or []
|
||||
provider_configs: Dict[str, Dict] = config.get("mail_provider_configs") or {}
|
||||
self.strategy: str = config.get("mail_strategy", "round_robin")
|
||||
|
||||
if not providers_list:
|
||||
legacy = config.get("mail_provider", "mailtm")
|
||||
providers_list = [legacy]
|
||||
provider_configs = {legacy: config.get("mail_config") or {}}
|
||||
|
||||
self._provider_names: List[str] = []
|
||||
self._providers: Dict[str, MailProvider] = {}
|
||||
self._failures: Dict[str, int] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._counter = itertools.count()
|
||||
|
||||
for name in providers_list:
|
||||
try:
|
||||
p = create_provider_by_name(name, provider_configs.get(name, {}))
|
||||
self._provider_names.append(name)
|
||||
self._providers[name] = p
|
||||
self._failures[name] = 0
|
||||
except Exception as e:
|
||||
logger.warning("创建邮箱提供商 %s 失败: %s", name, e)
|
||||
|
||||
if not self._providers:
|
||||
if providers_list:
|
||||
raise RuntimeError(f"邮箱提供商配置无效: {', '.join(str(n) for n in providers_list)}")
|
||||
fallback = create_provider_by_name("mailtm", {})
|
||||
self._provider_names = ["mailtm"]
|
||||
self._providers = {"mailtm": fallback}
|
||||
self._failures = {"mailtm": 0}
|
||||
|
||||
def next_provider(self) -> Tuple[str, MailProvider]:
|
||||
with self._lock:
|
||||
names = self._provider_names
|
||||
if not names:
|
||||
raise RuntimeError("无可用邮箱提供商")
|
||||
|
||||
if self.strategy == "random":
|
||||
name = random.choice(names)
|
||||
elif self.strategy == "failover":
|
||||
name = min(names, key=lambda n: self._failures.get(n, 0))
|
||||
else:
|
||||
idx = next(self._counter) % len(names)
|
||||
name = names[idx]
|
||||
return name, self._providers[name]
|
||||
|
||||
def providers(self) -> List[Tuple[str, MailProvider]]:
|
||||
with self._lock:
|
||||
return [(n, self._providers[n]) for n in self._provider_names]
|
||||
|
||||
def report_success(self, provider_name: str) -> None:
|
||||
with self._lock:
|
||||
self._failures[provider_name] = max(0, self._failures.get(provider_name, 0) - 1)
|
||||
|
||||
def report_failure(self, provider_name: str) -> None:
|
||||
with self._lock:
|
||||
self._failures[provider_name] = self._failures.get(provider_name, 0) + 1
|
||||
|
||||
|
||||
# ==================== 工厂函数 ====================
|
||||
|
||||
|
||||
def create_provider_by_name(provider_type: str, mail_cfg: Dict[str, Any]) -> MailProvider:
|
||||
"""根据提供商名称和单独配置创建实例"""
|
||||
provider_type = provider_type.lower().strip()
|
||||
api_base = str(mail_cfg.get("api_base", "")).strip()
|
||||
|
||||
if provider_type == "moemail":
|
||||
return MoeMailProvider(
|
||||
api_base=api_base or "https://your-moemail-api.example.com",
|
||||
api_key=str(mail_cfg.get("api_key", "")).strip(),
|
||||
)
|
||||
elif provider_type == "duckmail":
|
||||
return DuckMailProvider(
|
||||
api_base=api_base or "https://api.duckmail.sbs",
|
||||
bearer_token=str(mail_cfg.get("bearer_token", "")).strip(),
|
||||
)
|
||||
elif provider_type == "cloudflare_temp_email":
|
||||
return CloudflareTempEmailProvider(
|
||||
api_base=api_base,
|
||||
admin_password=str(mail_cfg.get("admin_password", "")).strip(),
|
||||
domain=str(mail_cfg.get("domain", "")).strip(),
|
||||
)
|
||||
elif provider_type == "mailtm":
|
||||
return MailTmProvider(api_base=api_base or "https://api.mail.tm")
|
||||
raise ValueError(f"未知邮箱提供商: {provider_type}")
|
||||
|
||||
|
||||
def create_provider(config: Dict[str, Any]) -> MailProvider:
|
||||
"""兼容旧配置格式的工厂函数"""
|
||||
provider_type = str(config.get("mail_provider", "mailtm")).lower()
|
||||
mail_cfg = config.get("mail_config") or {}
|
||||
return create_provider_by_name(provider_type, mail_cfg)
|
||||
1061
openai_pool_orchestrator/pool_maintainer.py
Executable file
1061
openai_pool_orchestrator/pool_maintainer.py
Executable file
File diff suppressed because it is too large
Load Diff
2120
openai_pool_orchestrator/register.py
Executable file
2120
openai_pool_orchestrator/register.py
Executable file
File diff suppressed because it is too large
Load Diff
3556
openai_pool_orchestrator/server.py
Executable file
3556
openai_pool_orchestrator/server.py
Executable file
File diff suppressed because it is too large
Load Diff
2780
openai_pool_orchestrator/static/app.js
Executable file
2780
openai_pool_orchestrator/static/app.js
Executable file
File diff suppressed because it is too large
Load Diff
694
openai_pool_orchestrator/static/index.html
Executable file
694
openai_pool_orchestrator/static/index.html
Executable file
@@ -0,0 +1,694 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenAI Pool Orchestrator</title>
|
||||
<meta name="description" content="OpenAI 账号池编排器 — Web 可视化界面" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ========== 头部 ========== -->
|
||||
<header>
|
||||
<div class="header-brand">
|
||||
<div class="logo">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/><circle cx="12" cy="15" r="2"/></svg>
|
||||
</div>
|
||||
<h1>Pool Orchestrator</h1>
|
||||
<span class="version">v5.2.1</span>
|
||||
</div>
|
||||
<nav class="tab-nav header-tab-nav" role="tablist" aria-label="主导航">
|
||||
<div class="segmented-control">
|
||||
<div class="segment-indicator" id="segmentIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="tabDashboard" role="tab" aria-selected="true" aria-controls="tabDashboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
<span>仪表盘</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tabConfig" role="tab" aria-selected="false" aria-controls="tabConfig">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
<span>配置中心</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div id="headerSub2apiChip" class="pool-chip status-idle interactive-chip" title="切换到 Sub2Api 视图" role="button" tabindex="0" aria-controls="dataPanelSub2Api" aria-pressed="true">
|
||||
<span class="pool-chip-name">Sub2Api</span>
|
||||
<span id="headerSub2apiLabel" class="pool-chip-value">-- / --</span>
|
||||
<span id="headerSub2apiDelta" class="pool-chip-delta">--</span>
|
||||
<div class="pool-chip-track">
|
||||
<div id="headerSub2apiBar" class="pool-chip-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="headerCpaChip" class="pool-chip status-idle interactive-chip" title="切换到 CPA 视图" role="button" tabindex="0" aria-controls="dataPanelCpa" aria-pressed="false">
|
||||
<span class="pool-chip-name">CPA</span>
|
||||
<span id="headerCpaLabel" class="pool-chip-value">-- / --</span>
|
||||
<span id="headerCpaDelta" class="pool-chip-delta">--</span>
|
||||
<div class="pool-chip-track">
|
||||
<div id="headerCpaBar" class="pool-chip-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="headerLocalTokenChip" class="pool-chip status-idle interactive-chip" title="切换到本地 Token 视图" role="button" tabindex="0" aria-controls="dataPanelLocalTokens" aria-pressed="false">
|
||||
<span class="pool-chip-name">Local Token</span>
|
||||
<span id="headerLocalTokenLabel" class="pool-chip-value">-- / --</span>
|
||||
<span id="headerLocalTokenDelta" class="pool-chip-delta">--</span>
|
||||
<div class="pool-chip-track">
|
||||
<div id="headerLocalTokenBar" class="pool-chip-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="statusBadge" class="status-badge idle">
|
||||
<span id="statusDot" class="status-dot"></span>
|
||||
<span id="statusText">空闲</span>
|
||||
</div>
|
||||
<button id="themeToggleBtn" class="theme-toggle-btn" type="button" aria-label="切换到明亮主题" title="切换到明亮主题">
|
||||
<span class="theme-toggle-icon" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-label">黑暗</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ========== 进度条 ========== -->
|
||||
<div class="progress-bar">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 仪表盘 Tab ========== -->
|
||||
<div id="tabDashboard" class="tab-panel active" role="tabpanel">
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
<!-- ===== 左栏:控制面板 ===== -->
|
||||
<aside class="sidebar">
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div class="panel-section">
|
||||
<div class="section-title">代理配置</div>
|
||||
<div class="proxy-row">
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="proxyInput" placeholder="http://127.0.0.1:7897" value="http://127.0.0.1:7897"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<button id="checkProxyBtn" class="btn btn-ghost btn-sm">检测</button>
|
||||
<button id="saveProxyBtn" class="btn btn-ghost btn-sm">保存</button>
|
||||
</div>
|
||||
<div id="proxyStatus" class="proxy-status">
|
||||
<span>点击「检测」验证代理可用性</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务控制 -->
|
||||
<div class="panel-section">
|
||||
<div class="section-title">任务控制</div>
|
||||
<div class="toggle-row">
|
||||
<label class="ios-toggle-label" for="multithreadCheck">
|
||||
<span class="ios-toggle">
|
||||
<input type="checkbox" id="multithreadCheck" />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>多线程</span>
|
||||
</label>
|
||||
<div class="thread-count-wrap">
|
||||
<label>线程</label>
|
||||
<input type="number" id="threadCountInput" value="3" min="1" max="10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label class="ios-toggle-label" for="autoRegisterCheck">
|
||||
<span class="ios-toggle">
|
||||
<input type="checkbox" id="autoRegisterCheck" />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>池不足自动注册</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-buttons">
|
||||
<button id="btnStart" class="btn btn-success">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
启动
|
||||
</button>
|
||||
<button id="btnStop" class="btn btn-danger" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
停止
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册步骤追踪 -->
|
||||
<div class="panel-section">
|
||||
<div class="section-title">注册进度</div>
|
||||
|
||||
<div class="progress-section-block">
|
||||
<div class="progress-subtitle">任务概览</div>
|
||||
<div id="taskOverview" class="task-overview-grid">
|
||||
<div class="task-overview-card empty">等待任务启动</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-section-block">
|
||||
<div class="progress-subtitle progress-subtitle-row">
|
||||
<span>Worker 列表</span>
|
||||
<button id="unlockFocusBtn" class="btn btn-ghost btn-sm" type="button" disabled>跟随后端焦点</button>
|
||||
</div>
|
||||
<div id="workerList" class="worker-list">
|
||||
<div class="worker-card empty">暂无 Worker 运行</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-section-block">
|
||||
<div class="progress-subtitle">焦点 Worker 明细</div>
|
||||
<div id="workerDetail" class="worker-detail-card empty">等待任务启动</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- 拖拽分隔条(左) -->
|
||||
<div class="resize-handle" id="resizeLeft"></div>
|
||||
|
||||
<!-- ===== 中栏:日志面板 ===== -->
|
||||
<main class="main-area">
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<div class="log-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span>实时日志</span>
|
||||
<span id="logCount" class="log-count-badge">0</span>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
<label class="ios-toggle-label log-autoscroll-label" for="autoScrollCheck">
|
||||
<span class="ios-toggle ios-toggle-sm">
|
||||
<input type="checkbox" id="autoScrollCheck" checked />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>自动滚动</span>
|
||||
</label>
|
||||
<button id="clearLogBtn" class="btn btn-ghost btn-sm">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logBody" class="log-body">
|
||||
<div class="log-entry log-placeholder">
|
||||
等待任务启动...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 拖拽分隔条(右) -->
|
||||
<div class="resize-handle" id="resizeRight"></div>
|
||||
|
||||
<!-- ===== 右栏:池状态 + Token ===== -->
|
||||
<aside class="data-panel">
|
||||
|
||||
<div class="remote-panel-wrap">
|
||||
<div class="data-panel-body">
|
||||
<div id="dataPanelSub2Api" class="data-panel-section active" role="tabpanel" aria-labelledby="headerSub2apiChip">
|
||||
<div class="pool-section-header">
|
||||
<div class="pool-section-title">
|
||||
<span>Sub2Api 账号池</span>
|
||||
|
||||
</div>
|
||||
<div class="pool-section-actions">
|
||||
<button id="sub2apiPoolRefreshBtn" class="btn btn-ghost btn-sm">刷新</button>
|
||||
<button id="sub2apiPoolMaintainBtn" class="btn btn-primary btn-sm">维护</button>
|
||||
<span id="sub2apiPoolMaintainStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pool-overview">
|
||||
<div class="pool-stat-card">
|
||||
<div id="sub2apiPoolTotal" class="stat-value blue">--</div>
|
||||
<div class="stat-label">总账号</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="sub2apiPoolNormal" class="stat-value green">--</div>
|
||||
<div class="stat-label">正常</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="sub2apiPoolError" class="stat-value red">--</div>
|
||||
<div class="stat-label">异常</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="sub2apiPoolThreshold" class="stat-value muted">--</div>
|
||||
<div class="stat-label">目标</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="sub2apiPoolPercent" class="stat-value">--</div>
|
||||
<div class="stat-label">充足率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub2api-account-wrap">
|
||||
<div class="token-filter-row">
|
||||
<select id="sub2apiAccountStatusFilter" class="token-filter-select">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="normal">仅正常</option>
|
||||
<option value="abnormal">仅异常</option>
|
||||
<option value="error">仅 Error</option>
|
||||
<option value="disabled">仅 Disabled</option>
|
||||
<option value="duplicate">仅重复</option>
|
||||
</select>
|
||||
<input id="sub2apiAccountKeyword" type="text" placeholder="筛选邮箱 / ID / 名称" autocomplete="off" autocapitalize="off" spellcheck="false" data-lpignore="true" />
|
||||
<button id="sub2apiAccountApplyBtn" class="btn btn-ghost btn-sm">筛选</button>
|
||||
<button id="sub2apiAccountResetBtn" class="btn btn-ghost btn-sm">重置</button>
|
||||
</div>
|
||||
<div class="token-filter-row account-toolbar">
|
||||
<label class="account-select-all" for="sub2apiAccountSelectAll">
|
||||
<input type="checkbox" id="sub2apiAccountSelectAll" />
|
||||
<span>全选当前页</span>
|
||||
</label>
|
||||
<button id="sub2apiAccountProbeBtn" class="btn btn-ghost btn-sm">测活选中</button>
|
||||
<button id="sub2apiAccountExceptionBtn" class="btn btn-ghost btn-sm">异常处理</button>
|
||||
<button id="sub2apiDuplicateScanBtn" class="btn btn-ghost btn-sm">重复检测</button>
|
||||
<button id="sub2apiDuplicateCleanBtn" class="btn btn-primary btn-sm">清理重复</button>
|
||||
<button id="sub2apiAccountDeleteBtn" class="btn btn-danger btn-sm">批量删除</button>
|
||||
<span id="sub2apiAccountSelection" class="inline-status"></span>
|
||||
</div>
|
||||
<div id="sub2apiAccountList" class="token-list sub2api-account-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">□</div>
|
||||
<span>正在加载 Sub2Api 账号列表...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pool-table-footer">
|
||||
<div class="account-pager">
|
||||
<button id="sub2apiAccountPrevBtn" class="btn btn-ghost btn-sm">上一页</button>
|
||||
<span id="sub2apiAccountPageInfo" class="pager-info">第 1/1 页 · 每页 20 条</span>
|
||||
<button id="sub2apiAccountNextBtn" class="btn btn-ghost btn-sm">下一页</button>
|
||||
<select id="sub2apiAccountPageSize" class="token-filter-select pager-size-select">
|
||||
<option value="20">20/页</option>
|
||||
<option value="50">50/页</option>
|
||||
<option value="100">100/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<span id="sub2apiAccountActionStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dataPanelCpa" class="data-panel-section" role="tabpanel" aria-labelledby="headerCpaChip">
|
||||
<div class="pool-section-header">
|
||||
<div class="pool-section-title">
|
||||
<span>CPA 账号池</span>
|
||||
</div>
|
||||
<div class="pool-section-actions">
|
||||
<button id="poolRefreshBtn" class="btn btn-ghost btn-sm">刷新</button>
|
||||
<button id="poolMaintainBtn" class="btn btn-primary btn-sm">维护</button>
|
||||
<span id="poolMaintainStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pool-overview">
|
||||
<div class="pool-stat-card">
|
||||
<div id="poolTotal" class="stat-value blue">--</div>
|
||||
<div class="stat-label">总账号</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="poolCandidates" class="stat-value green">--</div>
|
||||
<div class="stat-label">正常</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="poolError" class="stat-value red">--</div>
|
||||
<div class="stat-label">异常</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="poolThreshold" class="stat-value muted">--</div>
|
||||
<div class="stat-label">目标</div>
|
||||
</div>
|
||||
<div class="pool-stat-card">
|
||||
<div id="poolPercent" class="stat-value">--</div>
|
||||
<div class="stat-label">充足率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-note-wrap">
|
||||
<div class="platform-note-card">
|
||||
<div class="platform-note-title">CPA 当前交互</div>
|
||||
<div class="platform-note-text">右侧面板展示池状态与维护动作,详细配置仍在配置中心;当前版本暂不提供 CPA 账号明细列表。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dataPanelLocalTokens" class="data-panel-section local-token-panel-section" role="tabpanel" aria-labelledby="headerLocalTokenChip">
|
||||
<div class="pool-section-header">
|
||||
<div class="pool-section-title">
|
||||
<span>本地 Token 池</span>
|
||||
</div>
|
||||
<div class="pool-section-actions">
|
||||
<button id="poolCopyRtBtn" class="btn btn-ghost btn-sm">复制 RT</button>
|
||||
<button id="poolExportBtn" class="btn btn-ghost btn-sm">导出</button>
|
||||
<button id="poolPwSyncBtn" class="btn btn-primary btn-sm">批量导入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pool-table-wrap local-token-wrap">
|
||||
<div class="token-filter-row">
|
||||
<select id="tokenFilterStatus" class="token-filter-select">
|
||||
<option value="all">全部</option>
|
||||
<option value="synced">仅已导入</option>
|
||||
<option value="unsynced">仅未导入</option>
|
||||
<option value="cpa">仅 CPA</option>
|
||||
<option value="sub2api">仅 Sub2Api</option>
|
||||
<option value="both">CPA + Sub2Api</option>
|
||||
</select>
|
||||
<input id="tokenFilterKeyword" type="text" placeholder="筛选邮箱/文件名" />
|
||||
<button id="tokenFilterApplyBtn" class="btn btn-ghost btn-sm">筛选</button>
|
||||
<button id="tokenFilterResetBtn" class="btn btn-ghost btn-sm">重置</button>
|
||||
</div>
|
||||
<div id="poolTokenList" class="token-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
</div>
|
||||
<span>暂无 Token</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ========== 配置中心 Tab ========== -->
|
||||
<div id="tabConfig" class="tab-panel" role="tabpanel">
|
||||
<div class="config-page">
|
||||
|
||||
<!-- 全局上传策略 -->
|
||||
<div class="config-card collapsible open" style="grid-column: span 2;">
|
||||
<div class="collapsible-trigger">
|
||||
<span>全局上传策略</span>
|
||||
<span class="collapse-icon">▶</span>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<div class="config-field" style="max-width:720px;">
|
||||
<label>上传策略</label>
|
||||
<select id="uploadMode">
|
||||
<option value="snapshot">串行补平台(先CPA后Sub2Api)</option>
|
||||
<option value="decoupled">双平台同传(单账号双上传)</option>
|
||||
</select>
|
||||
<div class="config-hint">
|
||||
串行模式更保守;并行模式会让单账号并发上传到两个平台。当前任务运行中切换策略,将在下轮任务生效。
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions" style="margin-top:8px;">
|
||||
<button id="uploadModeSaveBtn" class="btn btn-primary btn-sm">保存策略</button>
|
||||
<span id="uploadModeStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求代理池配置 -->
|
||||
<div class="config-card collapsible open" style="grid-column: span 2;">
|
||||
<div class="collapsible-trigger">
|
||||
<span>请求代理池配置</span>
|
||||
<span class="collapse-icon">▶</span>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<div class="config-hint" style="margin-top:0;">
|
||||
在每次请求前动态取号。默认接口将附带 <code>api_key</code>、<code>count</code>、<code>country</code> 参数。
|
||||
</div>
|
||||
<div class="toggle-row" style="margin-top:10px;">
|
||||
<label class="ios-toggle-label" for="proxyPoolEnabled">
|
||||
<span class="ios-toggle ios-toggle-sm">
|
||||
<input type="checkbox" id="proxyPoolEnabled" />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>启用请求前代理池</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label>代理池 API</label>
|
||||
<input type="text" id="proxyPoolApiUrl" value="https://zenproxy.top/api/fetch" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>认证方式</label>
|
||||
<select id="proxyPoolAuthMode">
|
||||
<option value="query">Query 参数</option>
|
||||
<option value="header">Header Bearer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>API Key(留空则保持原值)</label>
|
||||
<input type="password" id="proxyPoolApiKey" placeholder="19c0ec43-8f76-4c97-81bc-bcda059eeba4" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-field" style="max-width:180px;">
|
||||
<label>Count</label>
|
||||
<input type="number" id="proxyPoolCount" value="1" min="1" max="20" />
|
||||
</div>
|
||||
<div class="config-field" style="max-width:180px;">
|
||||
<label>Country</label>
|
||||
<input type="text" id="proxyPoolCountry" value="US" maxlength="8" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button id="proxyPoolTestBtn" class="btn btn-ghost btn-sm">测试代理池取号</button>
|
||||
<button id="proxyPoolSaveBtn" class="btn btn-primary btn-sm">保存代理池配置</button>
|
||||
<span id="proxyPoolStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPA 平台配置 -->
|
||||
<div class="config-card collapsible open">
|
||||
<div class="collapsible-trigger">
|
||||
<span>CPA 平台配置</span>
|
||||
<span class="collapse-icon">▶</span>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<div class="config-field">
|
||||
<label>Base URL</label>
|
||||
<input type="text" id="cpaBaseUrl" placeholder="https://your-cpa-server.com" autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label>Token</label>
|
||||
<input type="password" id="cpaToken" placeholder="CPA 登录密码" autocomplete="off" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>目标阈值</label>
|
||||
<input type="number" id="cpaMinCandidates" value="800" min="1" />
|
||||
</div>
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>使用率阈值(%)</label>
|
||||
<input type="number" id="cpaUsedPercent" value="95" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row" style="margin-top:10px;">
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label class="ios-toggle-label" for="cpaAutoMaintain">
|
||||
<span class="ios-toggle ios-toggle-sm">
|
||||
<input type="checkbox" id="cpaAutoMaintain" />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>自动维护</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>间隔(分钟)</label>
|
||||
<input type="number" id="cpaInterval" value="30" min="5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button id="cpaTestBtn" class="btn btn-ghost btn-sm">测试连接</button>
|
||||
<button id="cpaSaveBtn" class="btn btn-primary btn-sm">保存</button>
|
||||
<span id="cpaStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub2Api 平台配置 -->
|
||||
<div class="config-card collapsible open">
|
||||
<div class="collapsible-trigger">
|
||||
<span>Sub2Api 平台配置</span>
|
||||
<span class="collapse-icon">▶</span>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<div class="config-field">
|
||||
<label>平台地址</label>
|
||||
<input type="text" id="sub2apiBaseUrl" value="" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>管理员邮箱</label>
|
||||
<input type="text" id="sub2apiEmail" value="" autocomplete="off" placeholder="admin@example.com" />
|
||||
</div>
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>密码</label>
|
||||
<input type="password" id="sub2apiPassword" value="" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-row" style="margin-top:6px;">
|
||||
<label class="ios-toggle-label" for="autoSyncCheck">
|
||||
<span class="ios-toggle ios-toggle-sm">
|
||||
<input type="checkbox" id="autoSyncCheck" checked />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>注册后自动导入</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="config-row" style="margin-top:10px;">
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>目标阈值</label>
|
||||
<input type="number" id="sub2apiMinCandidates" value="200" min="1" />
|
||||
</div>
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>维护间隔(分钟)</label>
|
||||
<input type="number" id="sub2apiInterval" value="30" min="5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-row" style="margin-top:6px;">
|
||||
<label class="ios-toggle-label" for="sub2apiAutoMaintain">
|
||||
<span class="ios-toggle ios-toggle-sm">
|
||||
<input type="checkbox" id="sub2apiAutoMaintain" />
|
||||
<span class="toggle-track"></span>
|
||||
</span>
|
||||
<span>自动维护</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="config-field" style="margin-top:10px;">
|
||||
<label>维护动作</label>
|
||||
<div class="config-hint" style="margin-top:0;">
|
||||
手动维护和自动维护都会按勾选项执行,可分别控制异常账号测活、仍异常删除与重复账号清理。
|
||||
</div>
|
||||
<div class="maintain-option-grid">
|
||||
<label class="maintain-option">
|
||||
<input type="checkbox" id="sub2apiMaintainRefreshAbnormal" checked />
|
||||
<span>异常账号测活</span>
|
||||
</label>
|
||||
<label class="maintain-option">
|
||||
<input type="checkbox" id="sub2apiMaintainDeleteAbnormal" checked />
|
||||
<span>删除仍异常账号</span>
|
||||
</label>
|
||||
<label class="maintain-option">
|
||||
<input type="checkbox" id="sub2apiMaintainDedupe" checked />
|
||||
<span>重复账号清理</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button id="sub2apiTestPoolBtn" class="btn btn-ghost btn-sm">测试连接</button>
|
||||
<button id="saveSyncConfigBtn" class="btn btn-primary btn-sm">保存</button>
|
||||
<span id="syncStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱提供商 -->
|
||||
<div class="config-card collapsible open" style="grid-column: span 2;">
|
||||
<div class="collapsible-trigger">
|
||||
<span>邮箱提供商</span>
|
||||
<span class="collapse-icon">▶</span>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<div class="mail-providers-group">
|
||||
<div class="provider-item" data-provider="mailtm">
|
||||
<label class="provider-toggle">
|
||||
<input type="checkbox" class="mail-provider-check" value="mailtm" checked />
|
||||
<span class="provider-check-mark"></span>
|
||||
Mail.tm (免费)
|
||||
</label>
|
||||
<div class="provider-config">
|
||||
<div class="config-field">
|
||||
<label>API 地址</label>
|
||||
<input type="text" data-key="api_base" placeholder="https://api.mail.tm" autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-item" data-provider="moemail">
|
||||
<label class="provider-toggle">
|
||||
<input type="checkbox" class="mail-provider-check" value="moemail" />
|
||||
<span class="provider-check-mark"></span>
|
||||
MoeMail (API Key)
|
||||
</label>
|
||||
<div class="provider-config" style="display:none;">
|
||||
<div class="config-field">
|
||||
<label>API 地址</label>
|
||||
<input type="text" data-key="api_base" placeholder="https://your-moemail-api.example.com" autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label>API Key</label>
|
||||
<input type="password" data-key="api_key" placeholder="MoeMail API Key" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-item" data-provider="duckmail">
|
||||
<label class="provider-toggle">
|
||||
<input type="checkbox" class="mail-provider-check" value="duckmail" />
|
||||
<span class="provider-check-mark"></span>
|
||||
DuckMail (Bearer Token)
|
||||
</label>
|
||||
<div class="provider-config" style="display:none;">
|
||||
<div class="config-field">
|
||||
<label>API 地址</label>
|
||||
<input type="text" data-key="api_base" placeholder="https://api.duckmail.sbs" autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label>Bearer Token</label>
|
||||
<input type="password" data-key="bearer_token" placeholder="DuckMail Bearer Token"
|
||||
autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-item" data-provider="cloudflare_temp_email">
|
||||
<label class="provider-toggle">
|
||||
<input type="checkbox" class="mail-provider-check" value="cloudflare_temp_email" />
|
||||
<span class="provider-check-mark"></span>
|
||||
Cloudflare Temp Email
|
||||
</label>
|
||||
<div class="provider-config" style="display:none;">
|
||||
<div class="config-field">
|
||||
<label>API 地址 (Worker URL)</label>
|
||||
<input type="text" data-key="api_base" placeholder="https://xxx.xxx.workers.dev" autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>Admin 密码</label>
|
||||
<input type="password" data-key="admin_password" placeholder="x-admin-auth"
|
||||
autocomplete="off" />
|
||||
</div>
|
||||
<div class="config-field" style="flex:1;">
|
||||
<label>分配域名</label>
|
||||
<input type="text" data-key="domain" placeholder="example.com" autocomplete="off"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-field" style="margin-top:10px;max-width:240px;">
|
||||
<label>路由策略</label>
|
||||
<select id="mailStrategySelect">
|
||||
<option value="round_robin">轮询</option>
|
||||
<option value="random">随机</option>
|
||||
<option value="failover">容错优先</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button id="mailTestBtn" class="btn btn-ghost btn-sm">测试连接</button>
|
||||
<button id="mailSaveBtn" class="btn btn-primary btn-sm">保存</button>
|
||||
<span id="mailStatus" class="inline-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
2226
openai_pool_orchestrator/static/style.css
Executable file
2226
openai_pool_orchestrator/static/style.css
Executable file
File diff suppressed because it is too large
Load Diff
38
pyproject.toml
Executable file
38
pyproject.toml
Executable file
@@ -0,0 +1,38 @@
|
||||
[project]
|
||||
name = "openai-pool-orchestrator"
|
||||
version = "2.0.0"
|
||||
description = "OpenAI 账号池编排器 — 自动化注册、Token 管理与多平台账号池维护"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
keywords = ["openai", "account-pool", "automation", "token-management"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
]
|
||||
dependencies = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.27",
|
||||
"curl-cffi>=0.6",
|
||||
"aiohttp>=3.9",
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openai-pool = "openai_pool_orchestrator.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["openai_pool_orchestrator*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
openai_pool_orchestrator = ["static/**/*"]
|
||||
5
requirements.txt
Executable file
5
requirements.txt
Executable file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
curl-cffi>=0.6
|
||||
aiohttp>=3.9
|
||||
requests>=2.31
|
||||
31
run.py
Executable file
31
run.py
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
快速启动脚本 - OpenAI Pool Orchestrator
|
||||
|
||||
用法:
|
||||
python run.py # 启动 Web 服务
|
||||
python run.py --cli # CLI 模式(单次注册)
|
||||
python run.py --cli --proxy http://127.0.0.1:7897
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 将项目根目录加入 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def main():
|
||||
if "--cli" in sys.argv:
|
||||
# CLI 模式:直接调用注册脚本
|
||||
sys.argv.remove("--cli")
|
||||
from openai_pool_orchestrator.register import main as cli_main
|
||||
cli_main()
|
||||
else:
|
||||
# Web 模式:启动 FastAPI 服务
|
||||
from openai_pool_orchestrator.__main__ import main as web_main
|
||||
web_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user