feat: add standalone CLI project
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
451
CONFIG_GUIDE.md
Normal 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
24
Dockerfile
Normal 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
191
README.md
Normal 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
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI application package for OpenAI Pool Orchestrator."""
|
||||
54
config/sync_config.example.json
Executable file
54
config/sync_config.example.json
Executable file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"proxy": "",
|
||||
"auto_register": false,
|
||||
"mail_providers": [
|
||||
"mailtm"
|
||||
],
|
||||
"mail_provider_configs": {
|
||||
"mailtm": {
|
||||
"api_base": "https://api.mail.tm"
|
||||
},
|
||||
"duckmail": {
|
||||
"api_base": "https://api.duckmail.sbs"
|
||||
},
|
||||
"moemail": {
|
||||
"api_base": "",
|
||||
"api_key": ""
|
||||
},
|
||||
"cloudflare_temp_email": {
|
||||
"api_base": "cloudflare worker后端密码,不要弄成前端的",
|
||||
"admin_password": "管理员密码",
|
||||
"domain": "xxx.cn 邮箱域名后缀"
|
||||
}
|
||||
},
|
||||
"mail_strategy": "round_robin",
|
||||
"multithread": false,
|
||||
"thread_count": 3,
|
||||
"base_url": "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
9
docker-compose.yml
Normal 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
422
main.py
Normal 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())
|
||||
25
openai_pool_orchestrator/__init__.py
Executable file
25
openai_pool_orchestrator/__init__.py
Executable 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"
|
||||
816
openai_pool_orchestrator/mail_providers.py
Executable file
816
openai_pool_orchestrator/mail_providers.py
Executable 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)
|
||||
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
2119
openai_pool_orchestrator/register.py
Executable file
2119
openai_pool_orchestrator/register.py
Executable file
File diff suppressed because it is too large
Load Diff
36
pyproject.toml
Normal file
36
pyproject.toml
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
curl-cffi>=0.6
|
||||
aiohttp>=3.9
|
||||
requests>=2.31
|
||||
20
run.py
Normal file
20
run.py
Normal 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
854
support.py
Normal 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] + "..."}
|
||||
Reference in New Issue
Block a user