feat: add standalone CLI project

This commit is contained in:
mmc
2026-03-19 07:36:14 +08:00
commit 26b238ec25
16 changed files with 6103 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
__pycache__/
*.py[cod]
*.so
*.egg-info/
dist/
build/
.venv/
venv/
.env/
data/sync_config.json
data/state.json
data/tokens/
data/*.bak
.DS_Store
Thumbs.db

451
CONFIG_GUIDE.md Normal file
View File

@@ -0,0 +1,451 @@
# Standalone CLI 配置说明
这个文件用来解释 `/root/standalone_cli/data/sync_config.json` 里常用字段该怎么填。
注意:`sync_config.json` 是 JSON不能直接写注释所以说明单独放在这里。
## 最少需要关心的字段
如果你只是先跑起来,优先看这几项:
- `proxy`
- `mail_providers`
- `mail_provider_configs`
- `cpa_base_url`
- `cpa_token`
- `base_url`
- `bearer_token``email` + `password`
## 字段说明
### 1. `proxy`
作用:注册 OpenAI 时使用的固定代理。
常见填写示例:
```json
"proxy": "http://127.0.0.1:7897"
```
如果你本地跑了 Clash / Mihomo / sing-box一般就是本机 HTTP 代理端口。
如果你不想走固定代理,也可以留空:
```json
"proxy": ""
```
## 2. `auto_register`
作用:用于池维护场景下,是否允许在账号不足时自动触发注册。
一般先保持:
```json
"auto_register": false
```
## 3. `mail_providers`
作用:启用哪些邮箱提供商。
当前可选值通常包括:
- `mailtm`
- `duckmail`
- `moemail`
- `cloudflare_temp_email`
示例:只用 `mailtm`
```json
"mail_providers": ["mailtm"]
```
示例:多个提供商轮询
```json
"mail_providers": ["mailtm", "duckmail", "moemail"]
```
## 4. `mail_provider_configs`
作用:为每个邮箱提供商填写自己的连接参数。
### `mailtm`
通常默认即可:
```json
"mailtm": {
"api_base": "https://api.mail.tm"
}
```
### `duckmail`
如果你要用 DuckMail
```json
"duckmail": {
"api_base": "https://api.duckmail.sbs"
}
```
### `moemail`
如果你有 MoeMail 服务:
```json
"moemail": {
"api_base": "https://your-moemail.example.com",
"api_key": "your_moemail_api_key"
}
```
### `cloudflare_temp_email`
如果你自己有 Cloudflare Worker 邮箱接口:
```json
"cloudflare_temp_email": {
"api_base": "https://your-worker.example.com",
"admin_password": "your_admin_password",
"domain": "example.com"
}
```
这里几项的含义:
- `api_base`:你的 Worker 接口地址
- `admin_password`:你的 Worker 后端管理密码
- `domain`:临时邮箱生成时使用的域名后缀
## 5. `mail_strategy`
作用:多个邮箱提供商启用时的调度策略。
可选值:
- `round_robin`:轮询,推荐默认
- `random`:随机
- `failover`:优先一个,失败再切下一个
推荐:
```json
"mail_strategy": "round_robin"
```
## 6. `base_url`
作用Sub2Api 平台地址。
示例:
```json
"base_url": "https://sub2api.example.com"
```
要求:
- 必须带 `http://``https://`
- 不能写成纯域名裸字符串
错误示例:
```json
"base_url": "sub2api.example.com"
```
正确示例:
```json
"base_url": "https://sub2api.example.com"
```
## 7. `bearer_token`
作用Sub2Api 管理员 Bearer Token。
如果你已经知道管理员 Token可以直接填
```json
"bearer_token": "your_sub2api_bearer_token"
```
## 8. `email` 和 `password`
作用:如果你不想手动填 `bearer_token`,可以填 Sub2Api 管理员账号密码,让 CLI 去登录并获取 token。
示例:
```json
"email": "admin@example.com",
"password": "your_password"
```
通常两种方式二选一:
- 方式 A`bearer_token`
- 方式 B`email` + `password`
## 9. `account_name`
作用:导入或展示时默认账号名称前缀。
一般默认就行:
```json
"account_name": "AutoReg"
```
## 10. `auto_sync`
作用:注册成功后,是否自动同步到 Sub2Api。
如果你希望注册后自动推送到 Sub2Api
```json
"auto_sync": true
```
否则:
```json
"auto_sync": false
```
## 11. `cpa_base_url`
作用CPA 平台地址。
示例:
```json
"cpa_base_url": "https://cpa.example.com"
```
要求同样是:
- 必须带 `http://``https://`
## 12. `cpa_token`
作用CPA 平台认证 token。
示例:
```json
"cpa_token": "your_cpa_token"
```
## 13. `min_candidates`
作用CPA 池健康阈值,低于这个候选数量就认为池子偏少。
例如:
```json
"min_candidates": 1000
```
如果你的使用规模不大,也可以调低,比如:
```json
"min_candidates": 100
```
## 14. `used_percent_threshold`
作用CPA 池已使用比例的告警阈值。
常见值:
```json
"used_percent_threshold": 95
```
## 15. `auto_maintain`
作用:是否自动执行 CPA 池维护。
```json
"auto_maintain": true
```
如果你只想手动维护,也可以关掉:
```json
"auto_maintain": false
```
## 16. `maintain_interval_minutes`
作用:自动维护的时间间隔,单位分钟。
```json
"maintain_interval_minutes": 30
```
## 17. `sub2api_min_candidates`
作用Sub2Api 池健康阈值。
```json
"sub2api_min_candidates": 200
```
## 18. `sub2api_auto_maintain`
作用:是否自动执行 Sub2Api 池维护。
```json
"sub2api_auto_maintain": false
```
## 19. `sub2api_maintain_actions`
作用Sub2Api 维护时具体做哪些动作。
默认:
```json
"sub2api_maintain_actions": {
"refresh_abnormal_accounts": true,
"delete_abnormal_accounts": true,
"dedupe_duplicate_accounts": true
}
```
这三项分别表示:
- `refresh_abnormal_accounts`:对异常账号做刷新/测活
- `delete_abnormal_accounts`:对仍异常的账号执行删除
- `dedupe_duplicate_accounts`:清理重复账号
## 20. `upload_mode`
作用:上传/同步策略。
一般保留默认:
```json
"upload_mode": "snapshot"
```
## 21. `proxy_pool_enabled`
作用:是否启用代理池,而不是固定 `proxy`
如果你没有代理池,建议保持:
```json
"proxy_pool_enabled": false
```
## 22. `proxy_pool_*`
这些字段用于代理池:
- `proxy_pool_api_url`
- `proxy_pool_auth_mode`
- `proxy_pool_api_key`
- `proxy_pool_count`
- `proxy_pool_country`
示例:
```json
"proxy_pool_enabled": true,
"proxy_pool_api_url": "https://your-proxy-pool.example.com/api/fetch",
"proxy_pool_auth_mode": "header",
"proxy_pool_api_key": "your_proxy_pool_api_key",
"proxy_pool_count": 1,
"proxy_pool_country": "US"
```
说明:
- `proxy_pool_auth_mode` 一般是 `header``query`
- `proxy_pool_country` 常见填 `US`
## 推荐的最小可用配置
如果你要先跑注册,再决定同步哪个平台,可以先这样:
```json
{
"proxy": "http://127.0.0.1:7897",
"auto_register": false,
"mail_providers": ["mailtm"],
"mail_provider_configs": {
"mailtm": {
"api_base": "https://api.mail.tm"
}
},
"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_token": "",
"min_candidates": 1000,
"used_percent_threshold": 95,
"auto_maintain": false,
"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": "",
"proxy_pool_count": 1,
"proxy_pool_country": "US"
}
```
## 如果你不知道怎么填
最常见的组合是:
- 只注册,不同步平台:
-`proxy`
- 填邮箱提供商配置
- `auto_sync = false`
- `cpa_base_url` / `base_url` 可以先留空
- 注册并同步到 Sub2Api
-`proxy`
- 填邮箱提供商配置
-`base_url`
-`bearer_token``email + password`
- `auto_sync = true`
- 注册并维护 CPA
-`proxy`
- 填邮箱提供商配置
-`cpa_base_url`
-`cpa_token`
## 建议命令
配置好之后常用命令是:
```bash
python3 /root/standalone_cli/run.py --json config show
python3 /root/standalone_cli/run.py register --once
python3 /root/standalone_cli/run.py cpa status
python3 /root/standalone_cli/run.py sub2api status
```

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc g++ make curl libssl-dev libffi-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt pyproject.toml README.md ./
COPY main.py run.py support.py ./
COPY openai_pool_orchestrator ./openai_pool_orchestrator
COPY config ./config
RUN mkdir -p /app/data/tokens && \
pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir -e .
VOLUME ["/app/data"]
ENTRYPOINT ["python", "run.py"]
CMD ["--help"]

191
README.md Normal file
View File

@@ -0,0 +1,191 @@
# Standalone OpenAI Pool CLI
一个纯 CLI 版的 OpenAI Pool Orchestrator可直接从 `/root/standalone_cli` 运行,不依赖原仓库路径。
## 目录结构
```text
/root/standalone_cli/
|- main.py # 主 CLI 入口
|- run.py # 便捷启动脚本
|- support.py # 配置/同步/状态辅助逻辑
|- requirements.txt # 运行依赖
|- README.md # 使用说明
|- config/
| `- sync_config.example.json # 配置模板
|- data/
| |- sync_config.json # 实际运行配置
| |- state.json # 成功/失败统计
| `- tokens/ # 本地 token 文件
`- openai_pool_orchestrator/
|- register.py # 注册核心逻辑
|- pool_maintainer.py # CPA/Sub2Api 维护逻辑
`- mail_providers.py # 邮箱提供商适配
```
## 安装依赖
```bash
cd /root/standalone_cli
pip install -r requirements.txt
```
如果你想用可编辑安装:
```bash
cd /root/standalone_cli
pip install -e .
openai-pool-standalone --help
```
## 初始化配置
```bash
python3 /root/standalone_cli/main.py config init
```
初始化后请编辑:
- `/root/standalone_cli/data/sync_config.json`
至少按需填写:
- `proxy`
- `cpa_base_url`
- `cpa_token`
- `base_url`
- `bearer_token``email` + `password`
- 邮箱提供商配置 `mail_provider_configs`
模板里的 URL 现在使用了更明确的占位值:
- `https://your-cpa.example.com`
- `https://your-sub2api.example.com`
留空的字段表示需要你自行填写真实值,例如:
- `cpa_token`
- `bearer_token`
- `password`
- `proxy_pool_api_key`
## 常用命令
### 查看帮助
```bash
python3 /root/standalone_cli/main.py --help
python3 /root/standalone_cli/run.py --help
```
### 查看当前配置
```bash
python3 /root/standalone_cli/main.py --json config show
```
### 单次注册
```bash
python3 /root/standalone_cli/main.py register --once
```
### 循环注册
```bash
python3 /root/standalone_cli/main.py register --sleep-min 5 --sleep-max 30
```
### 指定代理注册
```bash
python3 /root/standalone_cli/main.py register --proxy http://127.0.0.1:7897 --once
```
### 查看本地 token
```bash
python3 /root/standalone_cli/main.py tokens --limit 20
```
### CPA 维护
```bash
python3 /root/standalone_cli/main.py cpa status
python3 /root/standalone_cli/main.py cpa check
python3 /root/standalone_cli/main.py cpa maintain
python3 /root/standalone_cli/main.py cpa upload-all
```
### Sub2Api 维护
```bash
python3 /root/standalone_cli/main.py sub2api status
python3 /root/standalone_cli/main.py sub2api check
python3 /root/standalone_cli/main.py sub2api sync-all
python3 /root/standalone_cli/main.py sub2api maintain
python3 /root/standalone_cli/main.py sub2api dedupe --apply
```
### 统计信息
```bash
python3 /root/standalone_cli/main.py stats
```
## 运行方式
两种方式等价:
```bash
python3 /root/standalone_cli/main.py --help
python3 /root/standalone_cli/run.py --help
```
其中 `python3 /root/standalone_cli/run.py` 在不带参数时会自动显示帮助,更适合首次使用。
安装为命令行后也可以这样运行:
```bash
openai-pool-standalone --help
openai-pool-standalone register --once
```
## Docker
构建镜像:
```bash
cd /root/standalone_cli
docker build -t openai-pool-standalone .
```
运行容器:
```bash
docker run --rm -it \
-v /root/standalone_cli/data:/app/data \
openai-pool-standalone config show
```
或使用 compose
```bash
cd /root/standalone_cli
docker compose up --build
```
## 验证命令
```bash
python3 -m compileall /root/standalone_cli
python3 /root/standalone_cli/main.py --json config show
python3 /root/standalone_cli/main.py --json stats
python3 /root/standalone_cli/run.py --help
```
## 说明
- 这个目录现在已经包含独立的核心代码,不再依赖 `/root/openai_pool_orchestrator-main`
- 运行配置与 token 只写入 `/root/standalone_cli/data/`
- 不要把真实密钥、token、邮箱密码提交到版本库

1
__init__.py Normal file
View File

@@ -0,0 +1 @@
"""CLI application package for OpenAI Pool Orchestrator."""

54
config/sync_config.example.json Executable file
View 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": "https://your-sub2api.example.com",
"bearer_token": "",
"email": "admin@example.com",
"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": "https://your-cpa.example.com",
"cpa_token": "",
"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": "",
"proxy_pool_count": 1,
"proxy_pool_country": "US"
}

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
openai-pool-standalone:
build: .
image: openai-pool-standalone:latest
container_name: openai-pool-standalone
restart: unless-stopped
volumes:
- /root/standalone_cli/data:/app/data
command: ["config", "show"]

422
main.py Normal file
View File

@@ -0,0 +1,422 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, List
PROJECT_ROOT = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from openai_pool_orchestrator import TOKENS_DIR, __version__
from openai_pool_orchestrator.register import _write_text_atomic, run as register_run
try:
from .support import (
check_proxy,
get_pool_maintainer,
get_sub2api_maintainer,
init_config_from_example,
iter_token_files,
load_state,
load_sync_config,
login_sub2api_once,
normalize_sub2api_maintain_actions,
print_json,
print_status_block,
read_token_file,
save_runtime_proxy,
save_sub2api_credentials,
sub2api_actions_description,
sync_all_tokens_to_sub2api,
sync_token_to_sub2api,
upload_all_tokens_to_cpa,
)
except ImportError:
from support import (
check_proxy,
get_pool_maintainer,
get_sub2api_maintainer,
init_config_from_example,
iter_token_files,
load_state,
load_sync_config,
login_sub2api_once,
normalize_sub2api_maintain_actions,
print_json,
print_status_block,
read_token_file,
save_runtime_proxy,
save_sub2api_credentials,
sub2api_actions_description,
sync_all_tokens_to_sub2api,
sync_token_to_sub2api,
upload_all_tokens_to_cpa,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="OpenAI Pool Orchestrator CLI")
parser.add_argument("--json", action="store_true", help="以 JSON 输出结果")
subparsers = parser.add_subparsers(dest="command")
register_parser = subparsers.add_parser("register", help="执行注册流程")
register_parser.add_argument("--proxy", default=None, help="代理地址,如 http://127.0.0.1:7897")
register_parser.add_argument("--once", action="store_true", help="只运行一次")
register_parser.add_argument("--sleep-min", type=int, default=5, help="循环模式最短等待秒数")
register_parser.add_argument("--sleep-max", type=int, default=30, help="循环模式最长等待秒数")
register_parser.set_defaults(handler=handle_register)
config_parser = subparsers.add_parser("config", help="配置管理")
config_subparsers = config_parser.add_subparsers(dest="config_command")
config_init = config_subparsers.add_parser("init", help="初始化配置文件到 data/")
config_init.set_defaults(handler=handle_config_init)
config_show = config_subparsers.add_parser("show", help="显示当前配置")
config_show.set_defaults(handler=handle_config_show)
config_proxy = config_subparsers.add_parser("proxy", help="保存运行代理")
config_proxy.add_argument("proxy", help="代理地址")
config_proxy.add_argument("--auto-register", action="store_true", help="同时启用池不足时自动注册")
config_proxy.set_defaults(handler=handle_config_proxy)
config_check_proxy = config_subparsers.add_parser("check-proxy", help="检测代理可用性")
config_check_proxy.add_argument("proxy", help="代理地址")
config_check_proxy.set_defaults(handler=handle_check_proxy)
config_sub2api = config_subparsers.add_parser("sub2api", help="保存 Sub2Api 配置并校验")
config_sub2api.add_argument("--base-url", required=True, help="Sub2Api 平台地址")
config_sub2api.add_argument("--bearer-token", default="", help="Bearer Token")
config_sub2api.add_argument("--email", default="", help="管理员邮箱")
config_sub2api.add_argument("--password", default="", help="管理员密码")
config_sub2api.add_argument("--account-name", default=None, help="默认账号名称")
config_sub2api.add_argument("--auto-sync", action="store_true", help="注册成功后自动同步 Sub2Api")
config_sub2api.set_defaults(handler=handle_config_sub2api)
config_login = config_subparsers.add_parser("sub2api-login", help="登录 Sub2Api 并保存 Bearer Token")
config_login.add_argument("--base-url", required=True, help="Sub2Api 平台地址")
config_login.add_argument("--email", required=True, help="管理员邮箱")
config_login.add_argument("--password", required=True, help="管理员密码")
config_login.set_defaults(handler=handle_sub2api_login)
tokens_parser = subparsers.add_parser("tokens", help="查看本地 token 文件")
tokens_parser.add_argument("--limit", type=int, default=20, help="最多显示数量")
tokens_parser.set_defaults(handler=handle_tokens)
cpa_parser = subparsers.add_parser("cpa", help="CPA 账号池命令")
cpa_subparsers = cpa_parser.add_subparsers(dest="cpa_command")
cpa_status = cpa_subparsers.add_parser("status", help="查看 CPA 池状态")
cpa_status.set_defaults(handler=handle_cpa_status)
cpa_check = cpa_subparsers.add_parser("check", help="测试 CPA 连接")
cpa_check.set_defaults(handler=handle_cpa_check)
cpa_maintain = cpa_subparsers.add_parser("maintain", help="执行 CPA 维护")
cpa_maintain.set_defaults(handler=handle_cpa_maintain)
cpa_upload = cpa_subparsers.add_parser("upload-all", help="上传本地全部 token 到 CPA")
cpa_upload.add_argument("--include-uploaded", action="store_true", help="包含已标记上传的 token")
cpa_upload.set_defaults(handler=handle_cpa_upload_all)
sub2api_parser = subparsers.add_parser("sub2api", help="Sub2Api 命令")
sub2api_subparsers = sub2api_parser.add_subparsers(dest="sub2api_command")
sub2api_status = sub2api_subparsers.add_parser("status", help="查看 Sub2Api 池状态")
sub2api_status.set_defaults(handler=handle_sub2api_status)
sub2api_check = sub2api_subparsers.add_parser("check", help="测试 Sub2Api 连接")
sub2api_check.set_defaults(handler=handle_sub2api_check)
sub2api_sync = sub2api_subparsers.add_parser("sync-all", help="同步本地全部 token 到 Sub2Api")
sub2api_sync.add_argument("--include-uploaded", action="store_true", help="包含已标记同步的 token")
sub2api_sync.set_defaults(handler=handle_sub2api_sync_all)
sub2api_sync_one = sub2api_subparsers.add_parser("sync-one", help="同步单个 token 到 Sub2Api")
sub2api_sync_one.add_argument("file", help="token 文件名或路径")
sub2api_sync_one.set_defaults(handler=handle_sub2api_sync_one)
sub2api_dedupe = sub2api_subparsers.add_parser("dedupe", help="Sub2Api 重复账号清理")
sub2api_dedupe.add_argument("--apply", action="store_true", help="实际执行删除,不仅预览")
sub2api_dedupe.set_defaults(handler=handle_sub2api_dedupe)
sub2api_handle = sub2api_subparsers.add_parser("handle-exception", help="处理异常账号")
sub2api_handle.add_argument("ids", nargs="*", type=int, help="指定账号 ID为空时处理全部异常账号")
sub2api_handle.add_argument("--no-delete", action="store_true", help="刷新后不删除仍异常账号")
sub2api_handle.set_defaults(handler=handle_sub2api_handle_exception)
sub2api_maintain = sub2api_subparsers.add_parser("maintain", help="执行 Sub2Api 综合维护")
sub2api_maintain.add_argument("--refresh-abnormal", action="store_true", help="仅显式开启异常测活")
sub2api_maintain.add_argument("--delete-abnormal", action="store_true", help="仅显式开启异常删除")
sub2api_maintain.add_argument("--dedupe-duplicate", action="store_true", help="仅显式开启重复清理")
sub2api_maintain.add_argument("--no-refresh-abnormal", action="store_true", help="关闭异常测活")
sub2api_maintain.add_argument("--no-delete-abnormal", action="store_true", help="关闭异常删除")
sub2api_maintain.add_argument("--no-dedupe-duplicate", action="store_true", help="关闭重复清理")
sub2api_maintain.set_defaults(handler=handle_sub2api_maintain)
stats_parser = subparsers.add_parser("stats", help="显示本地累计统计")
stats_parser.set_defaults(handler=handle_stats)
return parser
def _print_result(args: argparse.Namespace, result: Any) -> int:
if args.json:
print_json(result)
return 0
if isinstance(result, dict):
print_json(result)
return 0
print(result)
return 0
def handle_register(args: argparse.Namespace) -> dict[str, Any]:
cfg = load_sync_config()
os.makedirs(TOKENS_DIR, exist_ok=True)
sleep_min = max(1, args.sleep_min)
sleep_max = max(sleep_min, args.sleep_max)
proxy = args.proxy if args.proxy is not None else str(cfg.get("proxy") or "").strip() or None
count = 0
runs: List[dict[str, Any]] = []
while True:
count += 1
print(f"\n[{time.strftime('%H:%M:%S')}] >>> 开始第 {count} 次注册流程 <<<")
try:
token_json = register_run(
proxy,
proxy_pool_config={
"enabled": bool(cfg.get("proxy_pool_enabled", False)),
"api_url": cfg.get("proxy_pool_api_url", ""),
"auth_mode": cfg.get("proxy_pool_auth_mode", "query"),
"api_key": cfg.get("proxy_pool_api_key", ""),
"count": cfg.get("proxy_pool_count", 1),
"country": cfg.get("proxy_pool_country", "US"),
},
)
except Exception as exc:
token_json = None
runs.append({"ok": False, "error": str(exc)})
print(f"[Error] 发生未捕获异常: {exc}")
if token_json:
token_data = json.loads(token_json)
email = str(token_data.get("email") or "unknown")
file_name = f"token_{email.replace('@', '_')}_{time.time_ns()}.json"
file_path = Path(TOKENS_DIR) / file_name
_write_text_atomic(str(file_path), token_json)
print(f"[*] 成功! Token 已保存至: {file_path}")
run_result: dict[str, Any] = {"ok": True, "file": file_name, "email": email}
cpa = get_pool_maintainer(cfg)
if cpa:
cpa_ok = cpa.upload_token(file_name, token_data, proxy=proxy or "")
run_result["cpa_uploaded"] = cpa_ok
print(f"[{'+' if cpa_ok else '-'}] CPA {'上传成功' if cpa_ok else '上传失败'}: {email}")
if cfg.get("auto_sync"):
try:
sync_result = sync_token_to_sub2api(file_path, cfg)
run_result["sub2api_sync"] = sync_result
if sync_result.get("ok"):
print(f"[+] Sub2Api 同步成功: {email}")
else:
print(f"[-] Sub2Api 同步失败: {email}")
except Exception as exc:
run_result["sub2api_sync"] = {"ok": False, "error": str(exc)}
print(f"[-] Sub2Api 同步异常: {exc}")
runs.append(run_result)
else:
if not runs or runs[-1].get("ok") is not False:
runs.append({"ok": False, "error": "本次注册失败"})
print("[-] 本次注册失败。")
if args.once:
break
wait_time = sleep_min if sleep_min == sleep_max else __import__("random").randint(sleep_min, sleep_max)
print(f"[*] 休息 {wait_time} 秒...")
time.sleep(wait_time)
return {"runs": runs}
def handle_config_init(args: argparse.Namespace) -> dict[str, Any]:
path = init_config_from_example(PROJECT_ROOT)
return {"ok": True, "config_file": str(path)}
def handle_config_show(args: argparse.Namespace) -> dict[str, Any]:
return load_sync_config()
def handle_config_proxy(args: argparse.Namespace) -> dict[str, Any]:
cfg = save_runtime_proxy(args.proxy, auto_register=args.auto_register)
return {"ok": True, "proxy": cfg.get("proxy", ""), "auto_register": cfg.get("auto_register", False)}
def handle_check_proxy(args: argparse.Namespace) -> dict[str, Any]:
return check_proxy(args.proxy)
def handle_config_sub2api(args: argparse.Namespace) -> dict[str, Any]:
cfg = save_sub2api_credentials(
base_url=args.base_url,
bearer_token=args.bearer_token,
email=args.email,
password=args.password,
account_name=args.account_name,
auto_sync=args.auto_sync,
)
return {"ok": True, "base_url": cfg.get("base_url", ""), "auto_sync": cfg.get("auto_sync", False)}
def handle_sub2api_login(args: argparse.Namespace) -> dict[str, Any]:
return login_sub2api_once(args.base_url, args.email, args.password)
def handle_tokens(args: argparse.Namespace) -> dict[str, Any]:
items = []
for index, path in enumerate(iter_token_files()):
if index >= max(1, args.limit):
break
try:
token_data = read_token_file(path)
items.append(
{
"file": path.name,
"email": token_data.get("email", ""),
"uploaded_platforms": token_data.get("uploaded_platforms", []),
}
)
except Exception as exc:
items.append({"file": path.name, "error": str(exc)})
return {"total_shown": len(items), "items": items}
def handle_cpa_status(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_pool_maintainer()
return maintainer.get_pool_status()
def handle_cpa_check(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_pool_maintainer()
return maintainer.test_connection()
def handle_cpa_maintain(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_pool_maintainer()
return maintainer.probe_and_clean_sync()
def handle_cpa_upload_all(args: argparse.Namespace) -> dict[str, Any]:
return upload_all_tokens_to_cpa(skip_uploaded=not args.include_uploaded)
def handle_sub2api_status(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_sub2api_maintainer()
status = maintainer.get_pool_status()
status["dashboard"] = maintainer.get_dashboard_stats()
return status
def handle_sub2api_check(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_sub2api_maintainer()
return maintainer.test_connection()
def handle_sub2api_sync_all(args: argparse.Namespace) -> dict[str, Any]:
return sync_all_tokens_to_sub2api(skip_uploaded=not args.include_uploaded)
def handle_sub2api_sync_one(args: argparse.Namespace) -> dict[str, Any]:
raw_path = Path(args.file)
file_path = raw_path if raw_path.is_absolute() else (Path(TOKENS_DIR) / args.file)
if not file_path.exists():
raise ValueError(f"token 文件不存在: {args.file}")
return sync_token_to_sub2api(file_path)
def handle_sub2api_dedupe(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_sub2api_maintainer()
return maintainer.dedupe_duplicate_accounts(dry_run=not args.apply)
def handle_sub2api_handle_exception(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_sub2api_maintainer()
return maintainer.handle_exception_accounts(account_ids=args.ids, delete_unresolved=not args.no_delete)
def handle_sub2api_maintain(args: argparse.Namespace) -> dict[str, Any]:
maintainer = require_sub2api_maintainer()
cfg = load_sync_config()
actions = normalize_sub2api_maintain_actions(cfg.get("sub2api_maintain_actions"))
overrides = {
"refresh_abnormal_accounts": (args.refresh_abnormal, args.no_refresh_abnormal),
"delete_abnormal_accounts": (args.delete_abnormal, args.no_delete_abnormal),
"dedupe_duplicate_accounts": (args.dedupe_duplicate, args.no_dedupe_duplicate),
}
for key, (enable, disable) in overrides.items():
if enable:
actions[key] = True
if disable:
actions[key] = False
result = maintainer.probe_and_clean_sync(actions=actions)
result["actions_text"] = sub2api_actions_description(actions)
return result
def handle_stats(args: argparse.Namespace) -> dict[str, Any]:
return load_state()
def require_pool_maintainer():
maintainer = get_pool_maintainer()
if not maintainer:
raise ValueError("CPA 未配置,请先在 data/sync_config.json 中填写 cpa_base_url 和 cpa_token")
return maintainer
def require_sub2api_maintainer():
maintainer = get_sub2api_maintainer()
if not maintainer:
raise ValueError("Sub2Api 未配置,请先在 data/sync_config.json 中填写 base_url 与认证信息")
return maintainer
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not getattr(args, "command", None):
parser.print_help()
return 0
try:
result = args.handler(args)
except Exception as exc:
if getattr(args, "json", False):
print_json({"ok": False, "error": str(exc)})
else:
print(f"错误: {exc}", file=sys.stderr)
return 1
if not getattr(args, "json", False) and args.command in {"cpa", "sub2api"} and isinstance(result, dict):
if args.command == "cpa" and args.cpa_command == "status":
print_status_block(f"OpenAI Pool Orchestrator v{__version__} - CPA 状态", result)
return 0
if args.command == "sub2api" and args.sub2api_command == "status":
print_status_block(f"OpenAI Pool Orchestrator v{__version__} - Sub2Api 状态", result)
return 0
return _print_result(args, result)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,25 @@
"""
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"

View File

@@ -0,0 +1,816 @@
"""
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 = "", domain: str = ""):
self.api_base = api_base.rstrip("/")
self.bearer_token = bearer_token
self.domain = str(domain).strip()
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:
domain = self.domain
if not domain:
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(),
domain=str(mail_cfg.get("domain", "")).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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

36
pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[project]
name = "standalone-openai-pool-cli"
version = "2.0.0"
description = "Standalone CLI for automated OpenAI registration and account-pool maintenance"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
keywords = ["openai", "account-pool", "automation", "cli", "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 = [
"curl-cffi>=0.6",
"aiohttp>=3.9",
"requests>=2.31",
]
[project.scripts]
openai-pool-standalone = "main:main"
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
py-modules = ["main", "run", "support"]
[tool.setuptools.packages.find]
include = ["openai_pool_orchestrator*"]

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
curl-cffi>=0.6
aiohttp>=3.9
requests>=2.31

20
run.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from main import main
def _default_args(argv: list[str]) -> list[str]:
if argv:
return argv
return ["--help"]
if __name__ == "__main__":
raise SystemExit(main(_default_args(sys.argv[1:])))

854
support.py Normal file
View File

@@ -0,0 +1,854 @@
from __future__ import annotations
import copy
import json
import os
import shutil
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openai_pool_orchestrator import CONFIG_FILE, STATE_FILE, TOKENS_DIR
from openai_pool_orchestrator.pool_maintainer import PoolMaintainer, Sub2ApiMaintainer
SUB2API_MAINTAIN_ACTION_DEFAULTS: Dict[str, bool] = {
"refresh_abnormal_accounts": True,
"delete_abnormal_accounts": True,
"dedupe_duplicate_accounts": True,
}
UPLOAD_PLATFORMS = ("cpa", "sub2api")
DEFAULT_CONFIG: Dict[str, Any] = {
"base_url": "",
"bearer_token": "",
"account_name": "AutoReg",
"auto_sync": False,
"cpa_base_url": "",
"cpa_token": "",
"min_candidates": 800,
"used_percent_threshold": 95,
"auto_maintain": False,
"maintain_interval_minutes": 30,
"upload_mode": "snapshot",
"mail_provider": "mailtm",
"mail_config": {"api_base": "https://api.mail.tm", "api_key": "", "bearer_token": ""},
"sub2api_min_candidates": 200,
"sub2api_auto_maintain": False,
"sub2api_maintain_interval_minutes": 30,
"sub2api_maintain_actions": copy.deepcopy(SUB2API_MAINTAIN_ACTION_DEFAULTS),
"proxy": "",
"auto_register": False,
"proxy_pool_enabled": True,
"proxy_pool_api_url": "https://zenproxy.top/api/fetch",
"proxy_pool_auth_mode": "query",
"proxy_pool_api_key": "19c0ec43-8f76-4c97-81bc-bcda059eeba4",
"proxy_pool_count": 1,
"proxy_pool_country": "US",
}
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in ("1", "true", "yes", "on"):
return True
if text in ("0", "false", "no", "off", ""):
return False
return default
def _normalize_service_url(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
if not text.startswith(("http://", "https://")):
return ""
return text.rstrip("/")
def normalize_sub2api_maintain_actions(raw: Any) -> Dict[str, bool]:
source = raw if isinstance(raw, dict) else {}
return {
key: _as_bool(source.get(key, default), default=default)
for key, default in SUB2API_MAINTAIN_ACTION_DEFAULTS.items()
}
def _write_json_atomic(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.stem}_", suffix=path.suffix, dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except OSError:
pass
def load_sync_config() -> Dict[str, Any]:
if CONFIG_FILE.exists():
try:
return normalize_config(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
except Exception:
pass
return normalize_config(copy.deepcopy(DEFAULT_CONFIG))
def normalize_config(cfg: Dict[str, Any]) -> Dict[str, Any]:
cfg = copy.deepcopy(cfg or {})
legacy = str(cfg.get("mail_provider", "mailtm") or "mailtm").strip().lower()
legacy_cfg = cfg.get("mail_config") or {}
if not isinstance(legacy_cfg, dict):
legacy_cfg = {}
raw_providers = cfg.get("mail_providers")
providers = raw_providers if isinstance(raw_providers, list) else []
providers = [str(name).strip().lower() for name in providers if str(name).strip()]
if not providers:
providers = [legacy]
raw_cfgs = cfg.get("mail_provider_configs")
provider_cfgs = raw_cfgs if isinstance(raw_cfgs, dict) else {}
for name in providers:
if name not in provider_cfgs or not isinstance(provider_cfgs.get(name), dict):
provider_cfgs[name] = {}
if legacy in provider_cfgs:
for key, value in legacy_cfg.items():
provider_cfgs[legacy].setdefault(key, value)
strategy = str(cfg.get("mail_strategy", "round_robin") or "round_robin").strip().lower()
if strategy not in ("round_robin", "random", "failover"):
strategy = "round_robin"
upload_mode = str(cfg.get("upload_mode", "snapshot") or "snapshot").strip().lower()
if upload_mode not in ("snapshot", "decoupled"):
upload_mode = "snapshot"
cfg["mail_providers"] = providers
cfg["mail_provider_configs"] = provider_cfgs
cfg["mail_strategy"] = strategy
cfg["mail_provider"] = providers[0]
cfg["upload_mode"] = upload_mode
cfg["auto_sync"] = _as_bool(cfg.get("auto_sync", False), default=False)
cfg["auto_maintain"] = _as_bool(cfg.get("auto_maintain", False), default=False)
cfg["sub2api_auto_maintain"] = _as_bool(cfg.get("sub2api_auto_maintain", False), default=False)
cfg["sub2api_maintain_actions"] = normalize_sub2api_maintain_actions(cfg.get("sub2api_maintain_actions"))
cfg["multithread"] = _as_bool(cfg.get("multithread", False), default=False)
cfg["auto_register"] = _as_bool(cfg.get("auto_register", False), default=False)
try:
cfg["thread_count"] = max(1, min(int(cfg.get("thread_count", 3)), 10))
except (TypeError, ValueError):
cfg["thread_count"] = 3
cfg["proxy_pool_enabled"] = _as_bool(cfg.get("proxy_pool_enabled", True), default=True)
proxy_pool_api_url = str(cfg.get("proxy_pool_api_url", DEFAULT_CONFIG["proxy_pool_api_url"]) or "").strip()
cfg["proxy_pool_api_url"] = proxy_pool_api_url or DEFAULT_CONFIG["proxy_pool_api_url"]
proxy_pool_auth_mode = str(cfg.get("proxy_pool_auth_mode", "query") or "").strip().lower()
if proxy_pool_auth_mode not in ("header", "query"):
proxy_pool_auth_mode = "query"
cfg["proxy_pool_auth_mode"] = proxy_pool_auth_mode
cfg["proxy_pool_api_key"] = str(cfg.get("proxy_pool_api_key", DEFAULT_CONFIG["proxy_pool_api_key"]) or "").strip()
try:
cfg["proxy_pool_count"] = max(1, min(int(cfg.get("proxy_pool_count", 1)), 20))
except (TypeError, ValueError):
cfg["proxy_pool_count"] = 1
cfg["proxy_pool_country"] = str(cfg.get("proxy_pool_country", "US") or "US").strip().upper() or "US"
cpa_base_url = str(cfg.get("cpa_base_url", "") or "").strip().rstrip("/")
if cpa_base_url.lower().endswith("/v0"):
cpa_base_url = cpa_base_url[:-3].rstrip("/")
cfg["cpa_base_url"] = cpa_base_url
return {**copy.deepcopy(DEFAULT_CONFIG), **cfg}
def save_sync_config(cfg: Dict[str, Any]) -> Dict[str, Any]:
normalized = normalize_config(cfg)
_write_json_atomic(CONFIG_FILE, normalized)
return normalized
def init_config_from_example(project_root: Path) -> Path:
example_path = project_root / "config" / "sync_config.example.json"
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
if CONFIG_FILE.exists():
return CONFIG_FILE
if example_path.exists():
shutil.copyfile(example_path, CONFIG_FILE)
else:
_write_json_atomic(CONFIG_FILE, copy.deepcopy(DEFAULT_CONFIG))
return CONFIG_FILE
def load_state() -> Dict[str, int]:
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {
"success": int(data.get("success", 0) or 0),
"fail": int(data.get("fail", 0) or 0),
}
except Exception:
pass
return {"success": 0, "fail": 0}
def iter_token_files() -> Iterable[Path]:
if not TOKENS_DIR.exists():
return []
return sorted(TOKENS_DIR.glob("*.json"), key=lambda path: path.name, reverse=True)
def read_token_file(path: Path) -> Dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"token file is not a JSON object: {path.name}")
return data
def extract_uploaded_platforms(token_data: Dict[str, Any]) -> List[str]:
platforms = set()
raw_platforms = token_data.get("uploaded_platforms")
if isinstance(raw_platforms, list):
for item in raw_platforms:
name = str(item).strip().lower()
if name in UPLOAD_PLATFORMS:
platforms.add(name)
if token_data.get("cpa_uploaded") or token_data.get("cpa_synced"):
platforms.add("cpa")
if token_data.get("sub2api_uploaded") or token_data.get("sub2api_synced") or token_data.get("synced"):
platforms.add("sub2api")
return [name for name in UPLOAD_PLATFORMS if name in platforms]
def is_sub2api_uploaded(token_data: Dict[str, Any]) -> bool:
return "sub2api" in extract_uploaded_platforms(token_data)
def mark_token_uploaded_platform(file_path: Path, platform: str) -> bool:
platform_name = str(platform).strip().lower()
if platform_name not in UPLOAD_PLATFORMS:
return False
try:
token_data = read_token_file(file_path)
platforms = extract_uploaded_platforms(token_data)
if platform_name not in platforms:
platforms.append(platform_name)
token_data["uploaded_platforms"] = [name for name in UPLOAD_PLATFORMS if name in set(platforms)]
token_data[f"{platform_name}_uploaded"] = True
token_data[f"{platform_name}_synced"] = True
if platform_name == "sub2api":
token_data["synced"] = True
uploaded_at = token_data.get("uploaded_at")
if not isinstance(uploaded_at, dict):
uploaded_at = {}
uploaded_at[platform_name] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
token_data["uploaded_at"] = uploaded_at
_write_json_atomic(file_path, token_data)
return True
except Exception:
return False
def get_pool_maintainer(cfg: Optional[Dict[str, Any]] = None) -> Optional[PoolMaintainer]:
config = cfg or load_sync_config()
base_url = _normalize_service_url(config.get("cpa_base_url", ""))
token = str(config.get("cpa_token", "")).strip()
if not base_url or not token:
return None
return PoolMaintainer(
cpa_base_url=base_url,
cpa_token=token,
min_candidates=int(config.get("min_candidates", 800)),
used_percent_threshold=int(config.get("used_percent_threshold", 95)),
)
def get_sub2api_maintainer(cfg: Optional[Dict[str, Any]] = None) -> Optional[Sub2ApiMaintainer]:
config = cfg or load_sync_config()
base_url = _normalize_service_url(config.get("base_url", ""))
bearer = str(config.get("bearer_token", "")).strip()
email = str(config.get("email", "")).strip()
password = str(config.get("password", "")).strip()
if not base_url:
return None
if not bearer and not (email and password):
return None
return Sub2ApiMaintainer(
base_url=base_url,
bearer_token=bearer,
min_candidates=int(config.get("sub2api_min_candidates", 200)),
email=email,
password=password,
)
def verify_sub2api_login(base_url: str, email: str, password: str) -> Dict[str, Any]:
from curl_cffi import requests as cffi_req
url = base_url.strip()
if not url.startswith(("http://", "https://")):
url = "https://" + url
login_url = url.rstrip("/") + "/api/v1/auth/login"
try:
resp = cffi_req.post(
login_url,
json={"email": email, "password": password},
impersonate="chrome",
timeout=15,
)
raw_body = resp.text
if resp.status_code != 200:
try:
err_body = json.loads(raw_body)
err_msg = err_body.get("message") or err_body.get("error") or raw_body[:200]
except json.JSONDecodeError:
err_msg = raw_body[:200]
return {"ok": False, "error": f"登录失败(HTTP {resp.status_code}): {err_msg}"}
body = json.loads(raw_body)
token = (
body.get("token")
or body.get("access_token")
or (body.get("data") or {}).get("token")
or (body.get("data") or {}).get("access_token")
or ""
)
return {"ok": True, "token": token}
except Exception as exc:
return {"ok": False, "error": f"请求异常: {exc}"}
def verify_sub2api_token(base_url: str, bearer_token: str) -> Dict[str, Any]:
from curl_cffi import requests as cffi_req
url = base_url.strip()
if not url.startswith(("http://", "https://")):
url = "https://" + url
verify_url = url.rstrip("/") + "/api/v1/admin/dashboard/stats"
try:
resp = cffi_req.get(
verify_url,
headers={"Authorization": f"Bearer {bearer_token}", "Accept": "application/json"},
params={"timezone": "Asia/Shanghai"},
impersonate="chrome",
timeout=15,
)
if resp.status_code != 200:
return {"ok": False, "error": f"Bearer Token 验证失败: HTTP {resp.status_code}"}
return {"ok": True}
except Exception as exc:
return {"ok": False, "error": f"Bearer Token 验证异常: {exc}"}
def save_sub2api_credentials(
*,
base_url: str,
bearer_token: str = "",
email: str = "",
password: str = "",
account_name: Optional[str] = None,
auto_sync: Optional[bool] = None,
) -> Dict[str, Any]:
cfg = load_sync_config()
normalized_base_url = base_url.strip()
if normalized_base_url and not normalized_base_url.startswith(("http://", "https://")):
normalized_base_url = "https://" + normalized_base_url
if not normalized_base_url:
raise ValueError("请填写平台地址")
saved_email = email.strip() or str(cfg.get("email", "") or "").strip()
saved_password = password.strip() if password else str(cfg.get("password", "") or "").strip()
saved_bearer = bearer_token.strip() or str(cfg.get("bearer_token", "") or "").strip()
verified_token = saved_bearer
if saved_email and saved_password:
verify = verify_sub2api_login(normalized_base_url, saved_email, saved_password)
if not verify.get("ok"):
raise ValueError(str(verify.get("error") or "登录校验失败"))
verified_token = str(verify.get("token") or "").strip() or saved_bearer
elif saved_bearer:
verify = verify_sub2api_token(normalized_base_url, saved_bearer)
if not verify.get("ok"):
raise ValueError(str(verify.get("error") or "Token 校验失败"))
else:
raise ValueError("请填写 Bearer Token 或邮箱和密码")
cfg["base_url"] = normalized_base_url
cfg["bearer_token"] = verified_token
cfg["email"] = saved_email
cfg["password"] = saved_password
if account_name is not None:
cfg["account_name"] = account_name.strip()
if auto_sync is not None:
cfg["auto_sync"] = bool(auto_sync)
return save_sync_config(cfg)
def build_account_payload(email: str, token_data: Dict[str, Any]) -> Dict[str, Any]:
access_token = token_data.get("access_token", "")
refresh_token = token_data.get("refresh_token", "")
id_token = token_data.get("id_token", "")
at_payload = decode_jwt_payload(access_token) if access_token else {}
at_auth = at_payload.get("https://api.openai.com/auth") or {}
chatgpt_account_id = at_auth.get("chatgpt_account_id", "") or token_data.get("account_id", "")
chatgpt_user_id = at_auth.get("chatgpt_user_id", "")
exp_timestamp = at_payload.get("exp", 0)
expires_at = exp_timestamp if isinstance(exp_timestamp, int) and exp_timestamp > 0 else int(time.time()) + 863999
it_payload = decode_jwt_payload(id_token) if id_token else {}
it_auth = it_payload.get("https://api.openai.com/auth") or {}
organization_id = it_auth.get("organization_id", "")
if not organization_id:
orgs = it_auth.get("organizations") or []
if orgs:
organization_id = (orgs[0] or {}).get("id", "")
return {
"name": email,
"notes": "",
"platform": "openai",
"type": "oauth",
"credentials": {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": 863999,
"expires_at": expires_at,
"chatgpt_account_id": chatgpt_account_id,
"chatgpt_user_id": chatgpt_user_id,
"organization_id": organization_id,
},
"extra": {"email": email},
"proxy_id": None,
"concurrency": 10,
"priority": 1,
"rate_multiplier": 1,
"group_ids": [2, 4],
"expires_at": None,
"auto_pause_on_expired": True,
}
def decode_jwt_payload(token: str) -> Dict[str, Any]:
try:
import base64
parts = token.split(".")
if len(parts) != 3:
return {}
payload = parts[1]
pad = 4 - len(payload) % 4
if pad != 4:
payload += "=" * pad
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
return json.loads(decoded.decode("utf-8"))
except Exception:
return {}
def push_account_api(base_url: str, bearer: str, email: str, token_data: Dict[str, Any]) -> Dict[str, Any]:
from curl_cffi import requests as cffi_req
url = base_url.rstrip("/") + "/api/v1/admin/accounts"
payload = build_account_payload(email, token_data)
try:
resp = cffi_req.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {bearer}",
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Referer": base_url.rstrip("/") + "/admin/accounts",
},
impersonate="chrome",
timeout=20,
)
return {"ok": resp.status_code in (200, 201), "status": resp.status_code, "body": resp.text[:300]}
except Exception as exc:
return {"ok": False, "status": 0, "body": str(exc)}
def update_sub2api_account_api(
base_url: str,
bearer: str,
account_id: int,
email: str,
token_data: Dict[str, Any],
) -> Dict[str, Any]:
from curl_cffi import requests as cffi_req
url = base_url.rstrip("/") + f"/api/v1/admin/accounts/{int(account_id)}"
create_payload = build_account_payload(email, token_data)
payload = {
"name": str(email or "").strip(),
"credentials": create_payload.get("credentials") if isinstance(create_payload.get("credentials"), dict) else {},
"extra": create_payload.get("extra") if isinstance(create_payload.get("extra"), dict) else {},
"concurrency": create_payload.get("concurrency", 10),
"priority": create_payload.get("priority", 1),
"status": "active",
"auto_pause_on_expired": True,
}
try:
resp = cffi_req.put(
url,
json=payload,
headers={
"Authorization": f"Bearer {bearer}",
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Referer": base_url.rstrip("/") + "/admin/accounts",
},
impersonate="chrome",
timeout=20,
)
return {"ok": resp.status_code in (200, 201), "status": resp.status_code, "body": resp.text[:300]}
except Exception as exc:
return {"ok": False, "status": 0, "body": str(exc)}
def _extract_sub2api_page_payload(body: Any) -> Dict[str, Any]:
if isinstance(body, dict):
data = body.get("data")
if isinstance(data, dict):
return data
return body
return {}
def _sub2api_item_matches_identity(item: Dict[str, Any], email: str, refresh_token: str) -> bool:
email_norm = str(email or "").strip().lower()
refresh_token_norm = str(refresh_token or "").strip()
name = str(item.get("name") or "").strip().lower()
extra = item.get("extra") if isinstance(item.get("extra"), dict) else {}
credentials = item.get("credentials") if isinstance(item.get("credentials"), dict) else {}
item_email = str(extra.get("email") or "").strip().lower()
item_refresh_token = str(credentials.get("refresh_token") or "").strip()
if refresh_token_norm and item_refresh_token and item_refresh_token == refresh_token_norm:
return True
if email_norm and (name == email_norm or item_email == email_norm):
return True
return False
def find_existing_sub2api_account(
base_url: str,
bearer: str,
email: str,
refresh_token: str,
max_pages: int = 8,
) -> Optional[Dict[str, Any]]:
from curl_cffi import requests as cffi_req
url = base_url.rstrip("/") + "/api/v1/admin/accounts"
email_norm = str(email or "").strip().lower()
refresh_token_norm = str(refresh_token or "").strip()
if not email_norm and not refresh_token_norm:
return None
headers = {"Authorization": f"Bearer {bearer}", "Accept": "application/json, text/plain, */*"}
page_size = 100
page = 1
scanned_without_search = 0
while page <= max_pages:
params: Dict[str, Any] = {"page": page, "page_size": page_size, "platform": "openai", "type": "oauth"}
if email_norm:
params["search"] = email_norm
try:
resp = cffi_req.get(url, params=params, headers=headers, impersonate="chrome", timeout=15)
if resp.status_code != 200:
return None
body = resp.json()
except Exception:
return None
data = _extract_sub2api_page_payload(body)
items = data.get("items") if isinstance(data.get("items"), list) else []
for item in items:
if isinstance(item, dict) and _sub2api_item_matches_identity(item, email_norm, refresh_token_norm):
return item
total_raw = data.get("total")
try:
total = int(total_raw) if total_raw is not None else 0
except (TypeError, ValueError):
total = 0
if len(items) < page_size or (total > 0 and page * page_size >= total):
break
page += 1
if refresh_token_norm:
page = 1
while page <= 3:
params = {"page": page, "page_size": page_size, "platform": "openai", "type": "oauth"}
try:
resp = cffi_req.get(url, params=params, headers=headers, impersonate="chrome", timeout=15)
if resp.status_code != 200:
return None
body = resp.json()
except Exception:
return None
data = _extract_sub2api_page_payload(body)
items = data.get("items") if isinstance(data.get("items"), list) else []
for item in items:
if isinstance(item, dict) and _sub2api_item_matches_identity(item, "", refresh_token_norm):
return item
scanned_without_search += len(items)
if len(items) < page_size or scanned_without_search >= 300:
break
page += 1
return None
def push_account_api_with_dedupe(
base_url: str,
bearer: str,
email: str,
token_data: Dict[str, Any],
check_before: bool = True,
check_after: bool = True,
) -> Dict[str, Any]:
refresh_token = str(token_data.get("refresh_token") or "").strip()
existing: Optional[Dict[str, Any]] = None
if check_before:
existing = find_existing_sub2api_account(base_url, bearer, email, refresh_token)
if existing is not None:
existing_id = existing.get("id")
try:
existing_int = int(existing_id)
except (TypeError, ValueError):
existing_int = None
if existing_int is not None and existing_int > 0:
update_result = update_sub2api_account_api(base_url, bearer, existing_int, email, token_data)
if update_result.get("ok"):
return {
"ok": True,
"status": int(update_result.get("status") or 200),
"body": "existing account updated",
"skipped": False,
"reason": "updated_existing_before_create",
"existing_id": existing_int,
}
return {
"ok": False,
"status": int(update_result.get("status") or 0),
"body": "existing account update failed",
"skipped": False,
"reason": "exists_before_create_update_failed",
"existing_id": existing_int,
}
return {
"ok": True,
"status": 200,
"body": "account already exists",
"skipped": True,
"reason": "exists_before_create",
"existing_id": existing_id,
}
result = push_account_api(base_url, bearer, email, token_data)
if result.get("ok"):
result["skipped"] = False
return result
if check_after:
existing = find_existing_sub2api_account(base_url, bearer, email, refresh_token)
if existing is not None:
return {
"ok": True,
"status": int(result.get("status") or 200),
"body": "request failed but account exists",
"skipped": True,
"reason": "exists_after_create",
"existing_id": existing.get("id"),
}
result.setdefault("skipped", False)
return result
def sync_token_to_sub2api(file_path: Path, cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = cfg or load_sync_config()
base_url = str(config.get("base_url", "") or "").strip()
bearer = str(config.get("bearer_token", "") or "").strip()
if not base_url or not bearer:
raise ValueError("请先配置 Sub2Api 平台地址和 Bearer Token")
token_data = read_token_file(file_path)
email = str(token_data.get("email") or file_path.name)
result = push_account_api_with_dedupe(base_url, bearer, email, token_data, check_before=True, check_after=True)
if result.get("ok"):
mark_token_uploaded_platform(file_path, "sub2api")
return {"file": file_path.name, "email": email, **result}
def sync_all_tokens_to_sub2api(cfg: Optional[Dict[str, Any]] = None, skip_uploaded: bool = True) -> Dict[str, Any]:
config = cfg or load_sync_config()
results = []
for path in iter_token_files():
try:
token_data = read_token_file(path)
if skip_uploaded and is_sub2api_uploaded(token_data):
results.append({"file": path.name, "email": token_data.get("email", path.name), "ok": True, "skipped": True})
continue
results.append(sync_token_to_sub2api(path, config))
except Exception as exc:
results.append({"file": path.name, "ok": False, "error": str(exc)})
return summarize_results(results)
def upload_all_tokens_to_cpa(cfg: Optional[Dict[str, Any]] = None, skip_uploaded: bool = True) -> Dict[str, Any]:
config = cfg or load_sync_config()
maintainer = get_pool_maintainer(config)
if not maintainer:
raise ValueError("请先配置 CPA 地址和 Token")
proxy = str(config.get("proxy") or "").strip()
results = []
for path in iter_token_files():
try:
token_data = read_token_file(path)
if skip_uploaded and "cpa" in extract_uploaded_platforms(token_data):
results.append({"file": path.name, "email": token_data.get("email", path.name), "ok": True, "skipped": True})
continue
ok = maintainer.upload_token(path.name, token_data, proxy=proxy)
if ok:
mark_token_uploaded_platform(path, "cpa")
results.append({"file": path.name, "email": token_data.get("email", path.name), "ok": ok, "skipped": False})
except Exception as exc:
results.append({"file": path.name, "ok": False, "error": str(exc)})
return summarize_results(results)
def summarize_results(results: List[Dict[str, Any]]) -> Dict[str, Any]:
ok_count = sum(1 for item in results if item.get("ok") and not item.get("skipped"))
skip_count = sum(1 for item in results if item.get("skipped"))
fail_count = sum(1 for item in results if not item.get("ok"))
return {"total": len(results), "ok": ok_count, "skipped": skip_count, "fail": fail_count, "results": results}
def print_json(data: Any) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2))
def print_status_block(title: str, data: Dict[str, Any]) -> None:
print(title)
for key, value in data.items():
print(f"- {key}: {value}")
def sub2api_actions_description(actions: Dict[str, bool]) -> str:
labels: List[str] = []
if actions.get("refresh_abnormal_accounts"):
labels.append("异常测活")
if actions.get("delete_abnormal_accounts"):
labels.append("异常清理")
if actions.get("dedupe_duplicate_accounts"):
labels.append("重复清理")
return "".join(labels) if labels else "无动作"
def save_runtime_proxy(proxy: str, auto_register: Optional[bool] = None) -> Dict[str, Any]:
cfg = load_sync_config()
cfg["proxy"] = proxy.strip()
if auto_register is not None:
cfg["auto_register"] = bool(auto_register)
return save_sync_config(cfg)
def check_proxy(proxy: str) -> Dict[str, Any]:
from curl_cffi import requests as cffi_req
import re
proxy_text = proxy.strip()
proxies = {"http": proxy_text, "https": proxy_text} if proxy_text else None
try:
try:
resp = cffi_req.get(
"https://cloudflare.com/cdn-cgi/trace",
proxies=proxies,
http_version="v2",
impersonate="chrome",
timeout=8,
)
except Exception as exc:
if "HTTP/3 is not supported over an HTTP proxy" not in str(exc):
raise
resp = cffi_req.get(
"https://cloudflare.com/cdn-cgi/trace",
proxies=proxies,
http_version="v1",
impersonate="chrome",
timeout=8,
)
text = resp.text
loc_match = re.search(r"^loc=(.+)$", text, re.MULTILINE)
loc = loc_match.group(1) if loc_match else "?"
supported = loc not in ("CN", "HK")
return {"ok": supported, "loc": loc, "error": None if supported else "所在地不支持"}
except Exception as exc:
return {"ok": False, "loc": None, "error": str(exc)}
def login_sub2api_once(base_url: str, email: str, password: str) -> Dict[str, Any]:
url = base_url.strip()
if not url:
raise ValueError("请填写平台地址")
if not url.startswith(("http://", "https://")):
url = "https://" + url
login_url = url.rstrip("/") + "/api/v1/auth/login"
payload = json.dumps({"email": email, "password": password}).encode("utf-8")
request = urllib.request.Request(
login_url,
data=payload,
method="POST",
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=15) as resp:
raw_body = resp.read().decode("utf-8")
body = json.loads(raw_body)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
err_body = json.loads(raw)
err_msg = err_body.get("message") or err_body.get("error") or raw[:200]
except json.JSONDecodeError:
err_msg = raw[:200]
raise ValueError(f"登录失败: {err_msg}") from exc
except Exception as exc:
raise ValueError(f"请求异常: {exc}") from exc
token = (
body.get("token")
or body.get("access_token")
or (body.get("data") or {}).get("token")
or (body.get("data") or {}).get("access_token")
or ""
)
if not token:
raise ValueError(f"响应中未找到 token 字段: {str(body)[:300]}")
cfg = load_sync_config()
cfg["base_url"] = url
cfg["bearer_token"] = token
cfg["email"] = email
cfg["password"] = password
save_sync_config(cfg)
return {"ok": True, "token_preview": token[:16] + "..."}