chore: initialize project repository
This commit is contained in:
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
Reference in New Issue
Block a user