From 26b238ec25dfbaf0505471dfc1eef2db6418ee82 Mon Sep 17 00:00:00 2001 From: mmc Date: Thu, 19 Mar 2026 07:36:14 +0800 Subject: [PATCH] feat: add standalone CLI project --- .gitignore | 17 + CONFIG_GUIDE.md | 451 ++++ Dockerfile | 24 + README.md | 191 ++ __init__.py | 1 + config/sync_config.example.json | 54 + docker-compose.yml | 9 + main.py | 422 ++++ openai_pool_orchestrator/__init__.py | 25 + openai_pool_orchestrator/mail_providers.py | 816 +++++++ openai_pool_orchestrator/pool_maintainer.py | 1061 ++++++++++ openai_pool_orchestrator/register.py | 2119 +++++++++++++++++++ pyproject.toml | 36 + requirements.txt | 3 + run.py | 20 + support.py | 854 ++++++++ 16 files changed, 6103 insertions(+) create mode 100644 .gitignore create mode 100644 CONFIG_GUIDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 __init__.py create mode 100755 config/sync_config.example.json create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100755 openai_pool_orchestrator/__init__.py create mode 100755 openai_pool_orchestrator/mail_providers.py create mode 100755 openai_pool_orchestrator/pool_maintainer.py create mode 100755 openai_pool_orchestrator/register.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 support.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d05ca9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONFIG_GUIDE.md b/CONFIG_GUIDE.md new file mode 100644 index 0000000..c3a5eb5 --- /dev/null +++ b/CONFIG_GUIDE.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5453626 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..41cf74e --- /dev/null +++ b/README.md @@ -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、邮箱密码提交到版本库 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..22370ec --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +"""CLI application package for OpenAI Pool Orchestrator.""" diff --git a/config/sync_config.example.json b/config/sync_config.example.json new file mode 100755 index 0000000..8f7a782 --- /dev/null +++ b/config/sync_config.example.json @@ -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" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8470885 --- /dev/null +++ b/docker-compose.yml @@ -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"] diff --git a/main.py b/main.py new file mode 100644 index 0000000..8f23c85 --- /dev/null +++ b/main.py @@ -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()) diff --git a/openai_pool_orchestrator/__init__.py b/openai_pool_orchestrator/__init__.py new file mode 100755 index 0000000..627bfbc --- /dev/null +++ b/openai_pool_orchestrator/__init__.py @@ -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" diff --git a/openai_pool_orchestrator/mail_providers.py b/openai_pool_orchestrator/mail_providers.py new file mode 100755 index 0000000..128797f --- /dev/null +++ b/openai_pool_orchestrator/mail_providers.py @@ -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]*?

", 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"(? 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) diff --git a/openai_pool_orchestrator/pool_maintainer.py b/openai_pool_orchestrator/pool_maintainer.py new file mode 100755 index 0000000..6bd5a6d --- /dev/null +++ b/openai_pool_orchestrator/pool_maintainer.py @@ -0,0 +1,1061 @@ +""" +账号池维护模块 +支持 CPA 平台和 Sub2Api 平台的探测、清理、计数和补号 +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import requests as _requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +try: + import aiohttp +except ImportError: + aiohttp = None + +logger = logging.getLogger(__name__) + +DEFAULT_MGMT_UA = "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal" + + +def _mgmt_headers(token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + +def _build_session(proxy: str = "") -> _requests.Session: + s = _requests.Session() + retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) + adapter = HTTPAdapter(max_retries=retry) + s.mount("https://", adapter) + s.mount("http://", adapter) + if proxy: + s.proxies = {"http": proxy, "https": proxy} + return s + + +def _get_item_type(item: Dict[str, Any]) -> str: + return str(item.get("type") or item.get("typo") or "") + + +def _safe_json(text: str) -> Dict[str, Any]: + try: + return json.loads(text) + except Exception: + return {} + + +def _extract_account_id(item: Dict[str, Any]) -> Optional[str]: + for key in ("chatgpt_account_id", "chatgptAccountId", "account_id", "accountId"): + val = item.get(key) + if val: + return str(val) + return None + + +def _parse_time_to_epoch(raw: Any) -> float: + text = str(raw or "").strip() + if not text: + return 0.0 + iso_text = text[:-1] + "+00:00" if text.endswith("Z") else text + try: + return datetime.fromisoformat(iso_text).timestamp() + except Exception: + pass + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(text, fmt).timestamp() + except Exception: + continue + return 0.0 + + +class PoolMaintainer: + def __init__( + self, + cpa_base_url: str, + cpa_token: str, + target_type: str = "codex", + min_candidates: int = 800, + used_percent_threshold: int = 95, + user_agent: str = DEFAULT_MGMT_UA, + ): + self.base_url = cpa_base_url.rstrip("/") + self.token = cpa_token + self.target_type = target_type + self.min_candidates = min_candidates + self.used_percent_threshold = used_percent_threshold + self.user_agent = user_agent + + def fetch_auth_files(self, timeout: int = 15) -> List[Dict[str, Any]]: + resp = _requests.get( + f"{self.base_url}/v0/management/auth-files", + headers=_mgmt_headers(self.token), + timeout=timeout, + ) + resp.raise_for_status() + raw = resp.json() + data = raw if isinstance(raw, dict) else {} + files = data.get("files", []) + return files if isinstance(files, list) else [] + + def get_pool_status(self, timeout: int = 15) -> Dict[str, Any]: + try: + files = self.fetch_auth_files(timeout) + candidates = [f for f in files if _get_item_type(f).lower() == self.target_type.lower()] + total = len(files) + cand_count = len(candidates) + return { + "total": total, + "candidates": cand_count, + "error_count": max(0, total - cand_count), + "threshold": self.min_candidates, + "healthy": cand_count >= self.min_candidates, + "percent": round(cand_count / self.min_candidates * 100, 1) if self.min_candidates > 0 else 100, + "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"), + "error": None, + } + except Exception as e: + return { + "total": 0, + "candidates": 0, + "error_count": 0, + "threshold": self.min_candidates, + "healthy": False, + "percent": 0, + "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"), + "error": str(e), + } + + def test_connection(self, timeout: int = 10) -> Dict[str, Any]: + try: + files = self.fetch_auth_files(timeout) + candidates = [f for f in files if _get_item_type(f).lower() == self.target_type.lower()] + return { + "ok": True, + "total": len(files), + "candidates": len(candidates), + "message": f"连接成功,共 {len(files)} 个账号,{len(candidates)} 个 {self.target_type} 账号", + } + except Exception as e: + return {"ok": False, "total": 0, "candidates": 0, "message": f"连接失败: {e}"} + + async def probe_accounts_async( + self, workers: int = 20, timeout: int = 10, retries: int = 1, + ) -> Dict[str, Any]: + if aiohttp is None: + raise RuntimeError("需要安装 aiohttp: pip install aiohttp") + + files = self.fetch_auth_files(timeout) + candidates = [f for f in files if _get_item_type(f).lower() == self.target_type.lower()] + + if not candidates: + return {"total": len(files), "candidates": 0, "invalid": [], "files": files} + + semaphore = asyncio.Semaphore(max(1, workers)) + connector = aiohttp.TCPConnector(limit=max(1, workers)) + client_timeout = aiohttp.ClientTimeout(total=max(1, timeout)) + + async def probe_one(session: aiohttp.ClientSession, item: Dict[str, Any]) -> Dict[str, Any]: + auth_index = item.get("auth_index") + name = item.get("name") or item.get("id") + result = { + "name": name, + "auth_index": auth_index, + "invalid_401": False, + "invalid_used_percent": False, + "used_percent": None, + "error": None, + } + if not auth_index: + result["error"] = "missing auth_index" + return result + + account_id = _extract_account_id(item) + call_header = { + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + } + if account_id: + call_header["Chatgpt-Account-Id"] = account_id + + payload = { + "authIndex": auth_index, + "method": "GET", + "url": "https://chatgpt.com/backend-api/wham/usage", + "header": call_header, + } + + for attempt in range(retries + 1): + try: + async with semaphore: + async with session.post( + f"{self.base_url}/v0/management/api-call", + headers={**_mgmt_headers(self.token), "Content-Type": "application/json"}, + json=payload, + timeout=timeout, + ) as resp: + text = await resp.text() + if resp.status >= 400: + raise RuntimeError(f"HTTP {resp.status}: {text[:200]}") + data = _safe_json(text) + sc = data.get("status_code") + result["invalid_401"] = sc == 401 + if sc == 200: + try: + body_data = _safe_json(data.get("body", "")) + used_pct = (body_data.get("rate_limit", {}).get("primary_window", {}).get("used_percent")) + if used_pct is not None: + result["used_percent"] = used_pct + result["invalid_used_percent"] = used_pct >= self.used_percent_threshold + except Exception: + pass + return result + except Exception as e: + result["error"] = str(e) + if attempt >= retries: + return result + return result + + async def delete_one(session: aiohttp.ClientSession, name: str) -> Dict[str, Any]: + encoded = quote(name, safe="") + try: + async with semaphore: + async with session.delete( + f"{self.base_url}/v0/management/auth-files?name={encoded}", + headers=_mgmt_headers(self.token), + timeout=timeout, + ) as resp: + text = await resp.text() + data = _safe_json(text) + ok = resp.status == 200 and data.get("status") == "ok" + return {"name": name, "deleted": ok} + except Exception: + return {"name": name, "deleted": False} + + invalid_list = [] + async with aiohttp.ClientSession(connector=connector, timeout=client_timeout, trust_env=True) as session: + tasks = [asyncio.create_task(probe_one(session, item)) for item in candidates] + for task in asyncio.as_completed(tasks): + result = await task + if result.get("invalid_401") or result.get("invalid_used_percent"): + invalid_list.append(result) + + return { + "total": len(files), + "candidates": len(candidates), + "invalid": invalid_list, + "files": files, + } + + async def clean_invalid_async(self, workers: int = 20, timeout: int = 10, retries: int = 1) -> Dict[str, Any]: + if aiohttp is None: + raise RuntimeError("需要安装 aiohttp: pip install aiohttp") + + probe_result = await self.probe_accounts_async(workers, timeout, retries) + invalid = probe_result["invalid"] + names = [str(r["name"]) for r in invalid if r.get("name")] + + deleted_ok = 0 + deleted_fail = 0 + + if names: + semaphore = asyncio.Semaphore(max(1, workers)) + connector = aiohttp.TCPConnector(limit=max(1, workers)) + client_timeout = aiohttp.ClientTimeout(total=max(1, timeout)) + + async with aiohttp.ClientSession(connector=connector, timeout=client_timeout, trust_env=True) as session: + async def do_delete(name: str) -> bool: + encoded = quote(name, safe="") + try: + async with semaphore: + async with session.delete( + f"{self.base_url}/v0/management/auth-files?name={encoded}", + headers=_mgmt_headers(self.token), + timeout=timeout, + ) as resp: + text = await resp.text() + data = _safe_json(text) + return resp.status == 200 and data.get("status") == "ok" + except Exception: + return False + + tasks = [asyncio.create_task(do_delete(n)) for n in names] + for task in asyncio.as_completed(tasks): + if await task: + deleted_ok += 1 + else: + deleted_fail += 1 + + return { + "total": probe_result["total"], + "candidates": probe_result["candidates"], + "invalid_count": len(invalid), + "deleted_ok": deleted_ok, + "deleted_fail": deleted_fail, + } + + def probe_and_clean_sync(self, workers: int = 20, timeout: int = 10, retries: int = 1) -> Dict[str, Any]: + return asyncio.run(self.clean_invalid_async(workers, timeout, retries)) + + def calculate_gap(self, current_candidates: Optional[int] = None) -> int: + if current_candidates is None: + status = self.get_pool_status() + if status.get("error"): + raise RuntimeError(f"CPA 池状态查询失败: {status['error']}") + current_candidates = status["candidates"] + gap = self.min_candidates - current_candidates + return max(0, gap) + + def upload_token(self, filename: str, token_data: Dict[str, Any], proxy: str = "") -> bool: + if not self.base_url or not self.token: + return False + content = json.dumps(token_data, ensure_ascii=False).encode("utf-8") + files = {"file": (filename, content, "application/json")} + headers = {"Authorization": f"Bearer {self.token}"} + + with _build_session(proxy) as session: + for attempt in range(3): + try: + resp = session.post( + f"{self.base_url}/v0/management/auth-files", + files=files, headers=headers, verify=False, timeout=30, + ) + if resp.status_code in (200, 201, 204): + return True + except Exception: + pass + if attempt < 2: + time.sleep(2 ** attempt) + return False + + +class Sub2ApiMaintainer: + """Sub2Api 平台池维护 — 通过 Admin API 管理账号池""" + + def __init__( + self, + base_url: str, + bearer_token: str, + min_candidates: int = 200, + email: str = "", + password: str = "", + ): + self.base_url = base_url.rstrip("/") + self.bearer_token = bearer_token + self.min_candidates = min_candidates + self.email = email + self.password = password + self._auth_lock = threading.Lock() + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.bearer_token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + def _login(self) -> str: + with _build_session() as session: + resp = session.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": self.email, "password": self.password}, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + token = ( + data.get("token") + or data.get("access_token") + or (data.get("data") or {}).get("token") + or (data.get("data") or {}).get("access_token") + or "" + ) + if token: + self.bearer_token = token + return token + + def _request(self, method: str, path: str, **kwargs) -> _requests.Response: + kwargs.setdefault("timeout", 15) + url = f"{self.base_url}{path}" + with _build_session() as session: + resp = session.request(method, url, headers=self._headers(), **kwargs) + if resp.status_code == 401 and self.email and self.password: + current_token = self.bearer_token + with self._auth_lock: + if self.bearer_token == current_token: + self._login() + refreshed_token = self.bearer_token + if refreshed_token or self.bearer_token != current_token: + resp = session.request(method, url, headers=self._headers(), **kwargs) + return resp + resp = session.request(method, url, headers=self._headers(), **kwargs) + return resp + + def get_dashboard_stats(self, timeout: int = 15) -> Dict[str, Any]: + resp = self._request( + "GET", "/api/v1/admin/dashboard/stats", + params={"timezone": "Asia/Shanghai"}, timeout=timeout, + ) + resp.raise_for_status() + data = resp.json() + return data.get("data") if isinstance(data.get("data"), dict) else data + + def list_accounts( + self, page: int = 1, page_size: int = 100, timeout: int = 15, + ) -> Dict[str, Any]: + params = { + "page": page, "page_size": page_size, + "platform": "openai", "type": "oauth", + } + resp = self._request( + "GET", "/api/v1/admin/accounts", + params=params, timeout=timeout, + ) + resp.raise_for_status() + data = resp.json() + return data.get("data") if isinstance(data.get("data"), dict) else data + + def _list_all_accounts(self, timeout: int = 15, page_size: int = 100) -> List[Dict[str, Any]]: + all_accounts: List[Dict[str, Any]] = [] + page = 1 + while True: + data = self.list_accounts(page=page, page_size=page_size, timeout=timeout) + items = data.get("items") or [] + if not isinstance(items, list): + items = [] + all_accounts.extend([i for i in items if isinstance(i, dict)]) + if not items or len(items) < page_size: + break + total = data.get("total") + if isinstance(total, int) and total > 0 and len(all_accounts) >= total: + break + page += 1 + return all_accounts + + def _account_identity(self, item: Dict[str, Any]) -> Dict[str, str]: + email = "" + rt = "" + extra = item.get("extra") + if isinstance(extra, dict): + email = str(extra.get("email") or "").strip().lower() + if not email: + name = str(item.get("name") or "").strip().lower() + if "@" in name: + email = name + creds = item.get("credentials") + if isinstance(creds, dict): + rt = str(creds.get("refresh_token") or "").strip() + return {"email": email, "refresh_token": rt} + + @staticmethod + def _account_sort_key(item: Dict[str, Any]) -> tuple[float, int]: + updated = _parse_time_to_epoch(item.get("updated_at") or item.get("updatedAt")) + try: + item_id = int(item.get("id") or 0) + except (TypeError, ValueError): + item_id = 0 + return (updated, item_id) + + @staticmethod + def _normalize_account_id(raw: Any) -> Optional[int]: + try: + account_id = int(raw) + except (TypeError, ValueError): + return None + if account_id <= 0: + return None + return account_id + + @staticmethod + def _is_abnormal_status(status: Any) -> bool: + return str(status or "").strip().lower() in ("error", "disabled") + + def _build_dedupe_plan(self, all_accounts: List[Dict[str, Any]], details_limit: int = 120) -> Dict[str, Any]: + id_to_account: Dict[int, Dict[str, Any]] = {} + parent: Dict[int, int] = {} + key_to_ids: Dict[str, List[int]] = {} + + for item in all_accounts: + acc_id = self._normalize_account_id(item.get("id")) + if acc_id is None: + continue + id_to_account[acc_id] = item + parent[acc_id] = acc_id + + identity = self._account_identity(item) + email = identity["email"] + refresh_token = identity["refresh_token"] + if email: + key_to_ids.setdefault(f"email:{email}", []).append(acc_id) + if refresh_token: + key_to_ids.setdefault(f"rt:{refresh_token}", []).append(acc_id) + + def find(x: int) -> int: + root = x + while parent[root] != root: + root = parent[root] + while parent[x] != x: + nxt = parent[x] + parent[x] = root + x = nxt + return root + + def union(a: int, b: int) -> None: + ra = find(a) + rb = find(b) + if ra != rb: + parent[rb] = ra + + for ids in key_to_ids.values(): + if len(ids) > 1: + head = ids[0] + for acc_id in ids[1:]: + union(head, acc_id) + + components: Dict[int, List[int]] = {} + for acc_id in id_to_account.keys(): + root = find(acc_id) + components.setdefault(root, []).append(acc_id) + + duplicate_groups = [ids for ids in components.values() if len(ids) > 1] + delete_ids: List[int] = [] + group_details: List[Dict[str, Any]] = [] + + for group_ids in duplicate_groups: + group_items = [id_to_account[i] for i in group_ids] + keep_item = max(group_items, key=self._account_sort_key) + keep_id = self._normalize_account_id(keep_item.get("id")) or 0 + group_delete_ids = sorted([i for i in group_ids if i != keep_id], reverse=True) + delete_ids.extend(group_delete_ids) + + if len(group_details) < details_limit: + emails_set = set() + for it in group_items: + identity = self._account_identity(it) + if identity["email"]: + emails_set.add(identity["email"]) + emails = sorted(emails_set) + group_details.append({ + "keep_id": keep_id, + "delete_ids": group_delete_ids, + "size": len(group_ids), + "emails": emails, + }) + + return { + "duplicate_groups": len(duplicate_groups), + "duplicate_accounts": sum(len(g) for g in duplicate_groups), + "delete_ids": delete_ids, + "groups_preview": group_details, + "truncated_groups": max(0, len(duplicate_groups) - len(group_details)), + } + + def list_account_inventory(self, timeout: int = 15) -> Dict[str, Any]: + all_accounts = self._list_all_accounts(timeout=timeout, page_size=100) + dedupe_plan = self._build_dedupe_plan( + all_accounts, + details_limit=max(1, len(all_accounts)), + ) + duplicate_delete_ids = { + int(account_id) + for account_id in (dedupe_plan.get("delete_ids") or []) + if isinstance(account_id, int) + } + duplicate_map: Dict[int, Dict[str, Any]] = {} + for group in dedupe_plan.get("groups_preview") or []: + keep_id = self._normalize_account_id(group.get("keep_id")) + delete_ids = [ + account_id + for account_id in ( + self._normalize_account_id(item) + for item in (group.get("delete_ids") or []) + ) + if account_id is not None + ] + group_ids = ([keep_id] if keep_id is not None else []) + delete_ids + group_size = max(1, int(group.get("size") or len(group_ids) or 1)) + emails = [str(email).strip().lower() for email in (group.get("emails") or []) if str(email).strip()] + for account_id in group_ids: + duplicate_map[account_id] = { + "group_size": group_size, + "keep_id": keep_id, + "delete_candidate": account_id in duplicate_delete_ids, + "emails": emails, + } + + items: List[Dict[str, Any]] = [] + abnormal_count = 0 + for raw_item in sorted(all_accounts, key=self._account_sort_key, reverse=True): + account_id = self._normalize_account_id(raw_item.get("id")) + if account_id is None: + continue + identity = self._account_identity(raw_item) + status = str(raw_item.get("status") or "").strip().lower() or "unknown" + if self._is_abnormal_status(status): + abnormal_count += 1 + duplicate_info = duplicate_map.get(account_id) or {} + items.append({ + "id": account_id, + "name": str(raw_item.get("name") or "").strip(), + "email": identity.get("email") or str(raw_item.get("name") or "").strip(), + "status": status, + "updated_at": raw_item.get("updated_at") or raw_item.get("updatedAt") or "", + "created_at": raw_item.get("created_at") or raw_item.get("createdAt") or "", + "is_duplicate": bool(duplicate_info), + "duplicate_group_size": int(duplicate_info.get("group_size") or 0), + "duplicate_keep": duplicate_info.get("keep_id") == account_id, + "duplicate_delete_candidate": bool(duplicate_info.get("delete_candidate")), + "duplicate_emails": duplicate_info.get("emails") or [], + }) + + return { + "total": len(items), + "error_count": abnormal_count, + "duplicate_groups": int(dedupe_plan.get("duplicate_groups", 0)), + "duplicate_accounts": int(dedupe_plan.get("duplicate_accounts", 0)), + "items": items, + } + + def _refresh_accounts_parallel(self, account_ids: List[int], timeout: int = 30, workers: int = 8) -> Dict[str, List[int]]: + success_ids: List[int] = [] + failed_ids: List[int] = [] + ids = [i for i in account_ids if isinstance(i, int) and i > 0] + if not ids: + return {"success_ids": success_ids, "failed_ids": failed_ids} + + pool_workers = max(1, min(workers, 16, len(ids))) + with ThreadPoolExecutor(max_workers=pool_workers) as executor: + future_to_id = { + executor.submit(self.refresh_account, account_id, timeout=timeout): account_id + for account_id in ids + } + for future in as_completed(future_to_id): + account_id = future_to_id[future] + try: + ok = bool(future.result()) + except Exception: + ok = False + if ok: + success_ids.append(account_id) + else: + failed_ids.append(account_id) + return {"success_ids": success_ids, "failed_ids": failed_ids} + + def _delete_accounts_parallel(self, account_ids: List[int], timeout: int = 15, workers: int = 12) -> Dict[str, Any]: + deleted_ok_ids: List[int] = [] + failed_ids: List[int] = [] + unique_ids = sorted({i for i in account_ids if isinstance(i, int) and i > 0}, reverse=True) + if not unique_ids: + return {"deleted_ok": 0, "deleted_fail": 0, "deleted_ok_ids": deleted_ok_ids, "failed_ids": failed_ids} + + pool_workers = max(1, min(workers, 24, len(unique_ids))) + with ThreadPoolExecutor(max_workers=pool_workers) as executor: + future_to_id = { + executor.submit(self.delete_account, account_id, timeout=timeout): account_id + for account_id in unique_ids + } + for future in as_completed(future_to_id): + account_id = future_to_id[future] + try: + ok = bool(future.result()) + except Exception: + ok = False + if ok: + deleted_ok_ids.append(account_id) + else: + failed_ids.append(account_id) + + return { + "deleted_ok": len(deleted_ok_ids), + "deleted_fail": len(failed_ids), + "deleted_ok_ids": deleted_ok_ids, + "failed_ids": failed_ids, + } + + def dedupe_duplicate_accounts(self, timeout: int = 15, dry_run: bool = True, details_limit: int = 120) -> Dict[str, Any]: + """ + 清理 Sub2Api 中 OpenAI OAuth 重复账号(按 email 或 refresh_token 判重)。 + - 同一连通重复组保留“最新”账号(updated_at 优先,其次 id 最大)。 + - dry_run=True 时仅预览,不执行删除。 + """ + all_accounts = self._list_all_accounts(timeout=timeout, page_size=100) + dedupe_plan = self._build_dedupe_plan(all_accounts, details_limit=details_limit) + delete_ids = dedupe_plan["delete_ids"] + deleted_ok = 0 + deleted_fail = 0 + failed_ids: List[int] = [] + if not dry_run and delete_ids: + delete_result = self._delete_accounts_parallel(delete_ids, timeout=timeout, workers=12) + deleted_ok = int(delete_result.get("deleted_ok", 0)) + deleted_fail = int(delete_result.get("deleted_fail", 0)) + failed_ids = list(delete_result.get("failed_ids") or []) + + return { + "dry_run": dry_run, + "total": len(all_accounts), + "duplicate_groups": int(dedupe_plan["duplicate_groups"]), + "duplicate_accounts": int(dedupe_plan["duplicate_accounts"]), + "to_delete": len(delete_ids), + "deleted_ok": deleted_ok, + "deleted_fail": deleted_fail, + "failed_delete_ids": failed_ids[:200], + "groups_preview": dedupe_plan["groups_preview"], + "truncated_groups": int(dedupe_plan["truncated_groups"]), + } + + def probe_accounts(self, account_ids: List[int], timeout: int = 30) -> Dict[str, Any]: + ids = sorted({ + account_id + for account_id in ( + self._normalize_account_id(item) + for item in (account_ids or []) + ) + if account_id is not None + }) + if not ids: + return { + "requested": 0, + "refreshed_ok": 0, + "refreshed_fail": 0, + "recovered": 0, + "still_abnormal": 0, + "details": [], + } + + before_status = self._list_accounts_by_ids(ids, timeout=timeout) + refresh_result = self._refresh_accounts_parallel(ids, timeout=max(30, timeout), workers=8) + success_ids = set(refresh_result.get("success_ids") or []) + failed_ids = set(refresh_result.get("failed_ids") or []) + + if success_ids: + time.sleep(2) + after_status = self._list_accounts_by_ids(ids, timeout=timeout) + + recovered_ids: List[int] = [] + abnormal_after_ids: List[int] = [] + details: List[Dict[str, Any]] = [] + for account_id in ids: + before = str(before_status.get(account_id) or "unknown").strip().lower() + after = str(after_status.get(account_id) or before or "unknown").strip().lower() + if self._is_abnormal_status(before) and not self._is_abnormal_status(after): + recovered_ids.append(account_id) + if self._is_abnormal_status(after): + abnormal_after_ids.append(account_id) + if len(details) < 200: + details.append({ + "id": account_id, + "before_status": before, + "after_status": after, + "refresh_ok": account_id in success_ids, + }) + + return { + "requested": len(ids), + "refreshed_ok": len(success_ids), + "refreshed_fail": len(failed_ids), + "recovered": len(recovered_ids), + "still_abnormal": len(abnormal_after_ids), + "details": details, + } + + def delete_accounts_batch(self, account_ids: List[int], timeout: int = 15) -> Dict[str, Any]: + ids = [ + account_id + for account_id in ( + self._normalize_account_id(item) + for item in (account_ids or []) + ) + if account_id is not None + ] + delete_result = self._delete_accounts_parallel(ids, timeout=timeout, workers=12) + return { + "requested": len({*ids}), + "deleted_ok": int(delete_result.get("deleted_ok", 0)), + "deleted_fail": int(delete_result.get("deleted_fail", 0)), + "deleted_ok_ids": list(delete_result.get("deleted_ok_ids") or []), + "failed_ids": list(delete_result.get("failed_ids") or []), + } + + def handle_exception_accounts( + self, + account_ids: Optional[List[int]] = None, + timeout: int = 30, + delete_unresolved: bool = True, + ) -> Dict[str, Any]: + requested_ids = [ + account_id + for account_id in ( + self._normalize_account_id(item) + for item in (account_ids or []) + ) + if account_id is not None + ] + + if requested_ids: + current_status = self._list_accounts_by_ids(requested_ids, timeout=timeout) + target_ids = [ + account_id + for account_id in requested_ids + if self._is_abnormal_status(current_status.get(account_id)) + ] + skipped_non_abnormal = max(0, len(set(requested_ids)) - len(target_ids)) + else: + all_accounts = self._list_all_accounts(timeout=timeout, page_size=100) + target_ids = [ + account_id + for account_id in ( + self._normalize_account_id(item.get("id")) + for item in all_accounts + if self._is_abnormal_status(item.get("status")) + ) + if account_id is not None + ] + skipped_non_abnormal = 0 + + unique_target_ids = sorted(set(target_ids)) + if not unique_target_ids: + return { + "requested": len(set(requested_ids)) if requested_ids else 0, + "targeted": 0, + "refreshed_ok": 0, + "refreshed_fail": 0, + "recovered": 0, + "remaining_abnormal": 0, + "deleted_ok": 0, + "deleted_fail": 0, + "skipped_non_abnormal": skipped_non_abnormal, + } + + refresh_result = self._refresh_accounts_parallel(unique_target_ids, timeout=max(30, timeout), workers=8) + if refresh_result.get("success_ids"): + time.sleep(2) + after_status = self._list_accounts_by_ids(unique_target_ids, timeout=timeout) + remaining_abnormal_ids = [ + account_id + for account_id in unique_target_ids + if self._is_abnormal_status(after_status.get(account_id)) + ] + remaining_abnormal_set = set(remaining_abnormal_ids) + recovered_ids = [ + account_id + for account_id in unique_target_ids + if account_id not in remaining_abnormal_set + ] + + delete_result = { + "deleted_ok": 0, + "deleted_fail": 0, + "deleted_ok_ids": [], + "failed_ids": [], + } + if delete_unresolved and remaining_abnormal_ids: + delete_result = self._delete_accounts_parallel(remaining_abnormal_ids, timeout=timeout, workers=12) + + return { + "requested": len(set(requested_ids)) if requested_ids else len(unique_target_ids), + "targeted": len(unique_target_ids), + "refreshed_ok": len(refresh_result.get("success_ids") or []), + "refreshed_fail": len(refresh_result.get("failed_ids") or []), + "recovered": len(recovered_ids), + "remaining_abnormal": len(remaining_abnormal_ids), + "deleted_ok": int(delete_result.get("deleted_ok", 0)), + "deleted_fail": int(delete_result.get("deleted_fail", 0)), + "deleted_ok_ids": list(delete_result.get("deleted_ok_ids") or []), + "failed_ids": list(delete_result.get("failed_ids") or []), + "skipped_non_abnormal": skipped_non_abnormal, + } + + def refresh_account(self, account_id: int, timeout: int = 30) -> bool: + try: + resp = self._request( + "POST", f"/api/v1/admin/accounts/{account_id}/refresh", + timeout=timeout, + ) + return resp.status_code in (200, 201) + except Exception: + return False + + def delete_account(self, account_id: int, timeout: int = 15) -> bool: + try: + resp = self._request( + "DELETE", f"/api/v1/admin/accounts/{account_id}", + timeout=timeout, + ) + return resp.status_code in (200, 204) + except Exception: + return False + + def get_pool_status(self, timeout: int = 15) -> Dict[str, Any]: + try: + all_accounts = self._list_all_accounts(timeout=timeout, page_size=100) + error = sum( + 1 for account in all_accounts + if self._is_abnormal_status(account.get("status")) + ) + total = len(all_accounts) + normal = max(0, total - error) + return { + "total": total, + "candidates": normal, + "error_count": error, + "threshold": self.min_candidates, + "healthy": normal >= self.min_candidates, + "percent": round(normal / self.min_candidates * 100, 1) if self.min_candidates > 0 else 100, + "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"), + "error": None, + } + except Exception as e: + return { + "total": 0, "candidates": 0, "error_count": 0, + "threshold": self.min_candidates, "healthy": False, + "percent": 0, "last_checked": time.strftime("%Y-%m-%d %H:%M:%S"), + "error": str(e), + } + + def test_connection(self, timeout: int = 10) -> Dict[str, Any]: + try: + status = self.get_pool_status(timeout) + total = int(status.get("total", 0)) + normal = int(status.get("candidates", 0)) + error = int(status.get("error_count", 0)) + return { + "ok": True, + "total": total, + "normal": normal, + "error": error, + "message": f"连接成功,共 {total} 个账号,{normal} 正常,{error} 异常", + } + except Exception as e: + return {"ok": False, "total": 0, "normal": 0, "error": 0, + "message": f"连接失败: {e}"} + + def _list_accounts_by_ids( + self, ids: List[int], timeout: int = 15, + ) -> Dict[int, str]: + """查询指定 ID 的账号当前状态,返回 {id: status}""" + result: Dict[int, str] = {} + id_set = set(ids) + page = 1 + while id_set: + data = self.list_accounts(page=page, page_size=100, timeout=timeout) + items = data.get("items") or [] + if not items: + break + for item in items: + aid = item.get("id") + if aid in id_set: + result[aid] = str(item.get("status", "")) + id_set.discard(aid) + total = data.get("total", 0) + if page * 100 >= total or len(items) < 100: + break + page += 1 + return result + + def probe_and_clean_sync(self, timeout: int = 15, actions: Optional[Dict[str, bool]] = None) -> Dict[str, Any]: + action_flags = { + "refresh_abnormal_accounts": bool((actions or {}).get("refresh_abnormal_accounts", True)), + "delete_abnormal_accounts": bool((actions or {}).get("delete_abnormal_accounts", True)), + "dedupe_duplicate_accounts": bool((actions or {}).get("dedupe_duplicate_accounts", True)), + } + started = time.time() + all_accounts = self._list_all_accounts(timeout=timeout, page_size=100) + + error_accounts = [ + account for account in all_accounts + if self._is_abnormal_status(account.get("status")) + ] + + error_ids = [ + self._normalize_account_id(acc.get("id")) + for acc in error_accounts + ] + error_ids = [i for i in error_ids if i is not None] + initial_error_ids = set(error_ids) + + refresh_result = {"success_ids": [], "failed_ids": []} + if action_flags["refresh_abnormal_accounts"] and error_ids: + refresh_result = self._refresh_accounts_parallel(error_ids, timeout=30, workers=8) + + refreshed_ids = list(refresh_result.get("success_ids") or []) + refresh_failed_ids = list(refresh_result.get("failed_ids") or []) + + current_accounts = all_accounts + current_error_ids = set(initial_error_ids) + if refreshed_ids: + time.sleep(2) + if action_flags["refresh_abnormal_accounts"] and (error_ids or refreshed_ids): + current_accounts = self._list_all_accounts(timeout=timeout, page_size=100) + current_error_ids = { + int(acc_id) for acc_id in ( + self._normalize_account_id(account.get("id")) + for account in current_accounts + if self._is_abnormal_status(account.get("status")) + ) if isinstance(acc_id, int) + } + recovered = len(initial_error_ids - current_error_ids) + + dedupe_plan = { + "duplicate_groups": 0, + "duplicate_accounts": 0, + "delete_ids": [], + "groups_preview": [], + "truncated_groups": 0, + } + duplicate_delete_ids: List[int] = [] + if action_flags["dedupe_duplicate_accounts"]: + dedupe_plan = self._build_dedupe_plan(current_accounts, details_limit=120) + duplicate_delete_ids = [int(i) for i in dedupe_plan["delete_ids"] if isinstance(i, int)] + normal_count = len(current_accounts) - len(current_error_ids) + + delete_targets: set[int] = set() + if action_flags["delete_abnormal_accounts"]: + delete_targets.update(current_error_ids) + if action_flags["dedupe_duplicate_accounts"]: + delete_targets.update(duplicate_delete_ids) + delete_result = self._delete_accounts_parallel(sorted(delete_targets, reverse=True), timeout=timeout, workers=12) + deleted_ok = int(delete_result.get("deleted_ok", 0)) + deleted_fail = int(delete_result.get("deleted_fail", 0)) + deleted_ok_ids = set(int(i) for i in (delete_result.get("deleted_ok_ids") or []) if isinstance(i, int)) + + deleted_from_error = len(deleted_ok_ids & set(current_error_ids)) + deleted_from_duplicate = len(deleted_ok_ids & set(duplicate_delete_ids)) + + elapsed_ms = int((time.time() - started) * 1000) + + return { + "actions": action_flags, + "total": len(current_accounts), "normal": normal_count, + "initial_error_count": len(initial_error_ids), + "error_count": len(current_error_ids), "refreshed": recovered, + "refresh_attempted": len(error_ids) if action_flags["refresh_abnormal_accounts"] else 0, + "refresh_failed": len(refresh_failed_ids), + "deleted_ok": deleted_ok, "deleted_fail": deleted_fail, + "duplicate_groups": int(dedupe_plan["duplicate_groups"]), + "duplicate_accounts": int(dedupe_plan["duplicate_accounts"]), + "duplicate_to_delete": len(duplicate_delete_ids), + "deleted_from_error": deleted_from_error, + "deleted_from_duplicate": deleted_from_duplicate, + "duration_ms": elapsed_ms, + } + + def calculate_gap(self, current_candidates: Optional[int] = None) -> int: + if current_candidates is None: + status = self.get_pool_status() + if status.get("error"): + raise RuntimeError(f"Sub2Api 池状态查询失败: {status['error']}") + current_candidates = status["candidates"] + return max(0, self.min_candidates - current_candidates) diff --git a/openai_pool_orchestrator/register.py b/openai_pool_orchestrator/register.py new file mode 100755 index 0000000..6de489d --- /dev/null +++ b/openai_pool_orchestrator/register.py @@ -0,0 +1,2119 @@ +import json +import os +import re +import sys +import time +import uuid +import math +import random +import string +import secrets +import socket +import hashlib +import base64 +import threading +import argparse +import queue +import tempfile +from http.cookies import SimpleCookie +from datetime import datetime, timezone, timedelta +from urllib.parse import urlparse, parse_qs, urlencode, quote +from dataclasses import dataclass +from typing import Any, Dict, Optional, Callable +import urllib.parse +import urllib.request +import urllib.error + +from curl_cffi import requests + +# ========================================== +# 日志事件发射器 +# ========================================== + + +class EventEmitter: + """ + 将注册流程中的日志事件发射到队列,供 SSE 消费。 + 同时支持 CLI 模式(直接 print)。 + """ + + def __init__( + self, + q: Optional[queue.Queue] = None, + cli_mode: bool = False, + defaults: Optional[Dict[str, Any]] = None, + ): + self._q = q + self._cli_mode = cli_mode + self._defaults = dict(defaults or {}) + + def emit(self, level: str, message: str, step: str = "", **extra: Any) -> None: + """ + level: "info" | "success" | "error" | "warn" + step: 可选的流程阶段标识,如 "check_proxy" / "create_email" 等 + """ + ts = datetime.now().strftime("%H:%M:%S") + event = { + "ts": ts, + "level": level, + "message": message, + "step": step, + } + if self._defaults: + event.update(self._defaults) + if extra: + event.update({k: v for k, v in extra.items() if v is not None}) + if self._cli_mode: + prefix_map = { + "info": "[*]", + "success": "[+]", + "error": "[Error]", + "warn": "[!]", + } + prefix = prefix_map.get(level, "[*]") + print(f"{prefix} {message}") + if self._q is not None: + try: + self._q.put_nowait(event) + except queue.Full: + pass + + def bind(self, **defaults: Any) -> "EventEmitter": + merged = dict(self._defaults) + merged.update({k: v for k, v in defaults.items() if v is not None}) + return EventEmitter(q=self._q, cli_mode=self._cli_mode, defaults=merged) + + def info(self, msg: str, step: str = "", **extra: Any) -> None: + self.emit("info", msg, step, **extra) + + def success(self, msg: str, step: str = "", **extra: Any) -> None: + self.emit("success", msg, step, **extra) + + def error(self, msg: str, step: str = "", **extra: Any) -> None: + self.emit("error", msg, step, **extra) + + def warn(self, msg: str, step: str = "", **extra: Any) -> None: + self.emit("warn", msg, step, **extra) + + +# 默认 CLI 发射器(兼容直接运行) +_cli_emitter = EventEmitter(cli_mode=True) + + +# ========================================== +# Mail.tm 临时邮箱 API +# ========================================== + +MAILTM_BASE = "https://api.mail.tm" +DEFAULT_PROXY_POOL_URL = "https://zenproxy.top/api/fetch" +DEFAULT_PROXY_POOL_AUTH_MODE = "query" +DEFAULT_PROXY_POOL_API_KEY = "19c0ec43-8f76-4c97-81bc-bcda059eeba4" +DEFAULT_PROXY_POOL_COUNT = 1 +DEFAULT_PROXY_POOL_COUNTRY = "US" +DEFAULT_HTTP_VERSION = "v2" +H3_PROXY_ERROR_HINT = "HTTP/3 is not supported over an HTTP proxy" +TRANSIENT_TLS_ERROR_HINTS = ( + "curl: (35)", + "TLS connect error", + "OPENSSL_internal:invalid library", + "SSL_ERROR_SYSCALL", +) +TRANSIENT_TLS_RETRY_COUNT = 2 +POOL_RELAY_RETRIES = 2 +POOL_PROXY_FETCH_RETRIES = 3 +POOL_RELAY_REQUEST_RETRIES = 2 + + +def _is_transient_tls_error(exc: Exception | str) -> bool: + message = str(exc or "") + return any(hint in message for hint in TRANSIENT_TLS_ERROR_HINTS) + + +def _call_with_http_fallback(request_func, url: str, **kwargs: Any): + """ + curl_cffi 在某些站点可能优先尝试 H3,遇到 HTTP 代理不支持时自动降级到 HTTP/1.1 重试。 + 对 curl TLS 握手异常(如 curl: (35))也进行有限重试,并优先降级到 HTTP/1.1。 + """ + try: + return request_func(url, **kwargs) + except Exception as exc: + message = str(exc) + if H3_PROXY_ERROR_HINT in message: + retry_kwargs = dict(kwargs) + retry_kwargs["http_version"] = "v1" + return request_func(url, **retry_kwargs) + if not _is_transient_tls_error(message): + raise + + last_exc: Exception = exc + candidate_kwargs_list = [dict(kwargs)] + if str(kwargs.get("http_version") or "").strip().lower() != "v1": + retry_kwargs = dict(kwargs) + retry_kwargs["http_version"] = "v1" + candidate_kwargs_list.append(retry_kwargs) + + for candidate_kwargs in candidate_kwargs_list: + for attempt in range(TRANSIENT_TLS_RETRY_COUNT): + time.sleep(min(0.35 * (attempt + 1), 1.0)) + try: + return request_func(url, **candidate_kwargs) + except Exception as retry_exc: + last_exc = retry_exc + retry_message = str(retry_exc) + if H3_PROXY_ERROR_HINT in retry_message and str(candidate_kwargs.get("http_version") or "").strip().lower() != "v1": + candidate_kwargs = dict(candidate_kwargs) + candidate_kwargs["http_version"] = "v1" + continue + if not _is_transient_tls_error(retry_message): + raise + raise last_exc + +def _normalize_proxy_value(proxy_value: Any) -> str: + value = str(proxy_value or "").strip().strip('"').strip("'") + if not value: + return "" + if value.startswith("{") or value.startswith("[") or value.startswith("<"): + return "" + if "://" in value: + return value + if ":" not in value: + return "" + return f"http://{value}" + + +def _to_proxies_dict(proxy_value: str) -> Optional[Dict[str, str]]: + normalized = _normalize_proxy_value(proxy_value) + if not normalized: + return None + return {"http": normalized, "https": normalized} + + +def _build_proxy_from_host_port(host: Any, port: Any, proxy_type: Any = "") -> str: + host_value = str(host or "").strip() + port_value = str(port or "").strip() + if not host_value or not port_value: + return "" + proxy_type_value = str(proxy_type or "").strip().lower() + if proxy_type_value in ("socks5", "socks", "shadowsocks"): + return _normalize_proxy_value(f"socks5://{host_value}:{port_value}") + return _normalize_proxy_value(f"http://{host_value}:{port_value}") + + +def _pool_host_from_api_url(api_url: str) -> str: + raw = str(api_url or "").strip() + if not raw: + return "" + if "://" not in raw: + raw = "https://" + raw + try: + parsed = urlparse(raw) + return str(parsed.hostname or "").strip() + except Exception: + return "" + + +def _pool_relay_url_from_fetch_url(api_url: str) -> str: + raw = str(api_url or "").strip() + if not raw: + return "" + if "://" not in raw: + raw = "https://" + raw + try: + parsed = urlparse(raw) + scheme = parsed.scheme or "https" + netloc = parsed.netloc + if not netloc: + return "" + return f"{scheme}://{netloc}/api/relay" + except Exception: + return "" + + +def _trace_via_pool_relay(pool_cfg: Dict[str, Any]) -> str: + relay_url = _pool_relay_url_from_fetch_url(str(pool_cfg.get("api_url") or "")) + if not relay_url: + raise RuntimeError("代理池 relay 地址解析失败") + + api_key = str(pool_cfg.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY + country = str(pool_cfg.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY + timeout = int(pool_cfg.get("timeout_seconds") or 10) + timeout = max(8, min(timeout, 30)) + + params = { + "api_key": api_key, + "url": "https://cloudflare.com/cdn-cgi/trace", + "country": country, + } + retry_count = max(1, int(pool_cfg.get("relay_retries") or POOL_RELAY_RETRIES)) + last_error = "" + for i in range(retry_count): + try: + resp = _call_with_http_fallback( + requests.get, + relay_url, + params=params, + impersonate="chrome", + timeout=timeout, + ) + if resp.status_code == 200: + return str(resp.text or "") + last_error = f"HTTP {resp.status_code}" + except Exception as exc: + last_error = str(exc) + if i < retry_count - 1: + time.sleep(min(0.3 * (i + 1), 1.0)) + raise RuntimeError(f"代理池 relay 请求失败: {last_error or 'unknown error'}") +def _extract_proxy_from_obj(obj: Any, relay_host: str = "") -> str: + if isinstance(obj, str): + return _normalize_proxy_value(obj) + if isinstance(obj, (list, tuple)): + for item in obj: + proxy = _extract_proxy_from_obj(item, relay_host) + if proxy: + return proxy + return "" + if isinstance(obj, dict): + local_port = obj.get("local_port") + if local_port in (None, ""): + local_port = obj.get("localPort") + if local_port not in (None, ""): + # ZenProxy 文档中的 local_port 是代理绑定端口,优先使用 api_url 主机名。 + if relay_host: + proxy = _normalize_proxy_value(f"http://{relay_host}:{local_port}") + if proxy: + return proxy + proxy = _normalize_proxy_value(f"http://127.0.0.1:{local_port}") + if proxy: + return proxy + + host = str(obj.get("ip") or obj.get("host") or obj.get("server") or "").strip() + port = str(obj.get("port") or "").strip() + proxy_type = obj.get("type") or obj.get("protocol") or obj.get("scheme") or "" + if host and port: + proxy = _build_proxy_from_host_port(host, port, proxy_type) + if proxy: + return proxy + + for key in ("proxy", "proxy_url", "url", "value", "result", "data", "proxy_list", "list", "proxies"): + if key in obj: + proxy = _extract_proxy_from_obj(obj.get(key), relay_host) + if proxy: + return proxy + + for value in obj.values(): + proxy = _extract_proxy_from_obj(value, relay_host) + if proxy: + return proxy + return "" + + +def _proxy_tcp_reachable(proxy_url: str, timeout_seconds: float = 1.2) -> bool: + value = str(proxy_url or "").strip() + if not value: + return False + if "://" not in value: + value = "http://" + value + try: + parsed = urlparse(value) + host = str(parsed.hostname or "").strip() + port = int(parsed.port or 0) + except Exception: + return False + if not host or port <= 0: + return False + try: + with socket.create_connection((host, port), timeout=timeout_seconds): + return True + except Exception: + return False + + +def _fetch_proxy_from_pool(pool_cfg: Dict[str, Any]) -> str: + enabled = bool(pool_cfg.get("enabled")) + if not enabled: + return "" + + api_url = str(pool_cfg.get("api_url") or DEFAULT_PROXY_POOL_URL).strip() or DEFAULT_PROXY_POOL_URL + auth_mode = str(pool_cfg.get("auth_mode") or DEFAULT_PROXY_POOL_AUTH_MODE).strip().lower() + if auth_mode not in ("header", "query"): + auth_mode = DEFAULT_PROXY_POOL_AUTH_MODE + api_key = str(pool_cfg.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY + relay_host = str(pool_cfg.get("relay_host") or "").strip() + if not relay_host: + relay_host = _pool_host_from_api_url(api_url) + try: + count = int(pool_cfg.get("count") or DEFAULT_PROXY_POOL_COUNT) + except (TypeError, ValueError): + count = DEFAULT_PROXY_POOL_COUNT + count = max(1, min(count, 20)) + country = str(pool_cfg.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY + timeout = int(pool_cfg.get("timeout_seconds") or 10) + timeout = max(3, min(timeout, 30)) + + headers: Dict[str, str] = {} + params: Dict[str, str] = {"count": str(count), "country": country} + if auth_mode == "query": + params["api_key"] = api_key + else: + headers["Authorization"] = f"Bearer {api_key}" + + resp = _call_with_http_fallback( + requests.get, + api_url, + headers=headers or None, + params=params or None, + http_version=DEFAULT_HTTP_VERSION, + impersonate="chrome", + timeout=timeout, + ) + if resp.status_code != 200: + raise RuntimeError(f"代理池请求失败: HTTP {resp.status_code}") + + proxy = "" + try: + payload = resp.json() + if isinstance(payload, dict): + proxies = payload.get("proxies") + if isinstance(proxies, list): + for item in proxies: + proxy = _extract_proxy_from_obj(item, relay_host) + if proxy: + break + if not proxy: + proxy = _extract_proxy_from_obj(payload, relay_host) + except Exception: + proxy = "" + + if not proxy: + proxy = _normalize_proxy_value(resp.text) + if not proxy: + raise RuntimeError("代理池响应中未找到可用代理") + return proxy + + +def _resolve_request_proxies( + default_proxies: Any = None, + proxy_selector: Optional[Callable[[], Any]] = None, +) -> Any: + if not proxy_selector: + return default_proxies + try: + selected = proxy_selector() + if selected is not None: + return selected + except Exception: + pass + return default_proxies + + +def _mailtm_headers(*, token: str = "", use_json: bool = False) -> Dict[str, str]: + headers = {"Accept": "application/json"} + if use_json: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _mailtm_domains(proxies: Any = None) -> list[str]: + resp = _call_with_http_fallback( + requests.get, + f"{MAILTM_BASE}/domains", + headers=_mailtm_headers(), + proxies=proxies, + http_version=DEFAULT_HTTP_VERSION, + impersonate="chrome", + timeout=15, + ) + if resp.status_code != 200: + raise RuntimeError(f"获取 Mail.tm 域名失败,状态码: {resp.status_code}") + + data = resp.json() + domains = [] + if isinstance(data, list): + items = data + elif isinstance(data, dict): + items = data.get("hydra:member") or data.get("items") or [] + else: + items = [] + + for item in items: + if not isinstance(item, dict): + continue + domain = str(item.get("domain") or "").strip() + is_active = item.get("isActive", True) + is_private = item.get("isPrivate", False) + if domain and is_active and not is_private: + domains.append(domain) + + return domains + + +def get_email_and_token( + proxies: Any = None, + emitter: EventEmitter = _cli_emitter, + proxy_selector: Optional[Callable[[], Any]] = None, +) -> tuple[str, str]: + """创建 Mail.tm 邮箱并获取 Bearer Token""" + try: + domains = _mailtm_domains(_resolve_request_proxies(proxies, proxy_selector)) + if not domains: + emitter.error("Mail.tm 没有可用域名", step="create_email") + return "", "" + domain = random.choice(domains) + + for _ in range(5): + local = f"oc{secrets.token_hex(5)}" + email = f"{local}@{domain}" + password = secrets.token_urlsafe(18) + + create_resp = _call_with_http_fallback( + requests.post, + f"{MAILTM_BASE}/accounts", + headers=_mailtm_headers(use_json=True), + json={"address": email, "password": password}, + proxies=_resolve_request_proxies(proxies, proxy_selector), + http_version=DEFAULT_HTTP_VERSION, + impersonate="chrome", + timeout=15, + ) + + if create_resp.status_code not in (200, 201): + continue + + token_resp = _call_with_http_fallback( + requests.post, + f"{MAILTM_BASE}/token", + headers=_mailtm_headers(use_json=True), + json={"address": email, "password": password}, + proxies=_resolve_request_proxies(proxies, proxy_selector), + http_version=DEFAULT_HTTP_VERSION, + impersonate="chrome", + timeout=15, + ) + + if token_resp.status_code == 200: + token = str(token_resp.json().get("token") or "").strip() + if token: + return email, token + + emitter.error("Mail.tm 邮箱创建成功但获取 Token 失败", step="create_email") + return "", "" + except Exception as e: + emitter.error(f"请求 Mail.tm API 出错: {e}", step="create_email") + return "", "" + + +def get_oai_code( + token: str, email: str, proxies: Any = None, emitter: EventEmitter = _cli_emitter, + stop_event: Optional[threading.Event] = None, + proxy_selector: Optional[Callable[[], Any]] = None, +) -> str: + """使用 Mail.tm Token 轮询获取 OpenAI 验证码""" + url_list = f"{MAILTM_BASE}/messages" + regex = r"(? str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _sha256_b64url_no_pad(s: str) -> str: + return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest()) + + +def _random_state(nbytes: int = 16) -> str: + return secrets.token_urlsafe(nbytes) + + +def _pkce_verifier() -> str: + return secrets.token_urlsafe(64) + + +def _parse_callback_url(callback_url: str) -> Dict[str, str]: + candidate = callback_url.strip() + if not candidate: + return {"code": "", "state": "", "error": "", "error_description": ""} + + if "://" not in candidate: + if candidate.startswith("?"): + candidate = f"http://localhost{candidate}" + elif any(ch in candidate for ch in "/?#") or ":" in candidate: + candidate = f"http://{candidate}" + elif "=" in candidate: + candidate = f"http://localhost/?{candidate}" + + parsed = urllib.parse.urlparse(candidate) + query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) + fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True) + + for key, values in fragment.items(): + if key not in query or not query[key] or not (query[key][0] or "").strip(): + query[key] = values + + def get1(k: str) -> str: + v = query.get(k, [""]) + return (v[0] or "").strip() + + code = get1("code") + state = get1("state") + error = get1("error") + error_description = get1("error_description") + + if code and not state and "#" in code: + code, state = code.split("#", 1) + + if not error and error_description: + error, error_description = error_description, "" + + return { + "code": code, + "state": state, + "error": error, + "error_description": error_description, + } + + +def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]: + if not id_token or id_token.count(".") < 2: + return {} + payload_b64 = id_token.split(".")[1] + pad = "=" * ((4 - (len(payload_b64) % 4)) % 4) + try: + payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii")) + return json.loads(payload.decode("utf-8")) + except Exception: + return {} + + +def _decode_jwt_segment(seg: str) -> Dict[str, Any]: + raw = (seg or "").strip() + if not raw: + return {} + pad = "=" * ((4 - (len(raw) % 4)) % 4) + try: + decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii")) + return json.loads(decoded.decode("utf-8")) + except Exception: + return {} + + +def _to_int(v: Any) -> int: + try: + return int(v) + except (TypeError, ValueError): + return 0 + + +def _post_form( + url: str, + data: Dict[str, str], + timeout: int = 30, + proxy: str = "", +) -> Dict[str, Any]: + body = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + handlers = [] + normalized_proxy = _normalize_proxy_value(proxy) + if normalized_proxy: + handlers.append(urllib.request.ProxyHandler({"http": normalized_proxy, "https": normalized_proxy})) + opener = urllib.request.build_opener(*handlers) + try: + with opener.open(req, timeout=timeout) as resp: + raw = resp.read() + if resp.status != 200: + raise RuntimeError( + f"token exchange failed: {resp.status}: {raw.decode('utf-8', 'replace')}" + ) + return json.loads(raw.decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read() + raise RuntimeError( + f"token exchange failed: {exc.code}: {raw.decode('utf-8', 'replace')}" + ) from exc + + +def _build_token_result(token_payload: Dict[str, Any], account_password: str = "") -> str: + access_token = str(token_payload.get("access_token") or "").strip() + refresh_token = str(token_payload.get("refresh_token") or "").strip() + id_token = str(token_payload.get("id_token") or "").strip() + expires_in = _to_int(token_payload.get("expires_in")) + + missing_fields = [ + name for name, value in ( + ("access_token", access_token), + ("refresh_token", refresh_token), + ("id_token", id_token), + ) if not value + ] + if missing_fields: + raise ValueError(f"token exchange missing fields: {', '.join(missing_fields)}") + + claims = _jwt_claims_no_verify(id_token) + email = str(claims.get("email") or "").strip() + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() + if not email or not account_id: + raise ValueError("token exchange missing email/account_id in id_token") + + now = int(time.time()) + expired_rfc3339 = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0)) + ) + now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + + config = { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + "last_refresh": now_rfc3339, + "expires_at": expired_rfc3339, + "email": email, + "type": "codex", + "expired": expired_rfc3339, + } + if account_password: + config["account_password"] = account_password + return json.dumps(config, ensure_ascii=False, separators=(",", ":")) + + +def _write_text_atomic(file_path: str, content: str) -> None: + directory = os.path.dirname(file_path) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=".tmp_", suffix=".json", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, file_path) + finally: + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except OSError: + pass + + +@dataclass(frozen=True) +class OAuthStart: + auth_url: str + state: str + code_verifier: str + redirect_uri: str + + +def generate_oauth_url( + *, redirect_uri: str = DEFAULT_REDIRECT_URI, scope: str = DEFAULT_SCOPE +) -> OAuthStart: + state = _random_state() + code_verifier = _pkce_verifier() + code_challenge = _sha256_b64url_no_pad(code_verifier) + + params = { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "prompt": "login", + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + } + auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}" + return OAuthStart( + auth_url=auth_url, + state=state, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + ) + + +def submit_callback_url( + *, + callback_url: str, + expected_state: str, + code_verifier: str, + redirect_uri: str = DEFAULT_REDIRECT_URI, + proxy: str = "", +) -> str: + cb = _parse_callback_url(callback_url) + if cb["error"]: + desc = cb["error_description"] + raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip()) + + if not cb["code"]: + raise ValueError("callback url missing ?code=") + if not cb["state"]: + raise ValueError("callback url missing ?state=") + if cb["state"] != expected_state: + raise ValueError("state mismatch") + + token_resp = _post_form( + TOKEN_URL, + { + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": cb["code"], + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + proxy=proxy, + ) + + return _build_token_result(token_resp) + + +# ========================================== +# 核心注册逻辑 +# ========================================== + +from . import TOKENS_DIR as _PKG_TOKENS_DIR + +TOKENS_DIR = str(_PKG_TOKENS_DIR) + + +def run( + proxy: Optional[str], + emitter: EventEmitter = _cli_emitter, + stop_event: Optional[threading.Event] = None, + mail_provider=None, + proxy_pool_config: Optional[Dict[str, Any]] = None, +) -> Optional[str]: + static_proxy = _normalize_proxy_value(proxy) + static_proxies: Any = _to_proxies_dict(static_proxy) + + pool_cfg_raw = proxy_pool_config or {} + pool_cfg = { + "enabled": bool(pool_cfg_raw.get("enabled", False)), + "api_url": str(pool_cfg_raw.get("api_url") or DEFAULT_PROXY_POOL_URL).strip() or DEFAULT_PROXY_POOL_URL, + "auth_mode": str(pool_cfg_raw.get("auth_mode") or DEFAULT_PROXY_POOL_AUTH_MODE).strip().lower() or DEFAULT_PROXY_POOL_AUTH_MODE, + "api_key": str(pool_cfg_raw.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY, + "count": pool_cfg_raw.get("count", DEFAULT_PROXY_POOL_COUNT), + "country": str(pool_cfg_raw.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY, + "timeout_seconds": int(pool_cfg_raw.get("timeout_seconds") or 10), + } + if pool_cfg["auth_mode"] not in ("header", "query"): + pool_cfg["auth_mode"] = DEFAULT_PROXY_POOL_AUTH_MODE + try: + pool_cfg["count"] = max(1, min(int(pool_cfg.get("count") or DEFAULT_PROXY_POOL_COUNT), 20)) + except (TypeError, ValueError): + pool_cfg["count"] = DEFAULT_PROXY_POOL_COUNT + + last_pool_proxy = "" + pool_fail_streak = 0 + warned_fallback = False + + def _next_proxy_value() -> str: + nonlocal last_pool_proxy, pool_fail_streak, warned_fallback + if pool_cfg["enabled"]: + max_fetch_retries = max(1, int(pool_cfg.get("fetch_retries") or POOL_PROXY_FETCH_RETRIES)) + last_error = "" + for _ in range(max_fetch_retries): + try: + fetched = _fetch_proxy_from_pool(pool_cfg) + if fetched and not _proxy_tcp_reachable(fetched): + last_error = f"代理池代理不可达: {fetched}" + continue + last_pool_proxy = fetched + pool_fail_streak = 0 + warned_fallback = False + return fetched + except Exception as e: + last_error = str(e) + + pool_fail_streak += 1 + if static_proxy: + if not warned_fallback: + emitter.warn(f"代理池不可用,回退固定代理: {last_error or 'unknown error'}", step="check_proxy") + warned_fallback = True + return static_proxy + if pool_fail_streak <= 3: + emitter.warn(f"代理池不可用: {last_error or 'unknown error'}", step="check_proxy") + return "" + return static_proxy + def _next_proxies() -> Any: + proxy_value = _next_proxy_value() + return _to_proxies_dict(proxy_value) + + # 随机 Chrome 指纹,避免 OpenAI 反机器人检测 + _chrome_profiles = [ + {"major": 119, "imp": "chrome119", "build": 6045, "patch": (123, 200), + "sec": '"Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"'}, + {"major": 120, "imp": "chrome120", "build": 6099, "patch": (62, 200), + "sec": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"'}, + {"major": 123, "imp": "chrome123", "build": 6312, "patch": (46, 170), + "sec": '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"'}, + {"major": 124, "imp": "chrome124", "build": 6367, "patch": (60, 180), + "sec": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"'}, + ] + _cp = random.choice(_chrome_profiles) + _chrome_full = f"{_cp['major']}.0.{_cp['build']}.{random.randint(*_cp['patch'])}" + _chrome_ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{_chrome_full} Safari/537.36" + + s = requests.Session(impersonate=_cp["imp"]) + s.headers.update({ + "User-Agent": _chrome_ua, + "Accept-Language": random.choice(["en-US,en;q=0.9", "en-US,en;q=0.9,zh-CN;q=0.8", "en,en-US;q=0.9"]), + "sec-ch-ua": _cp["sec"], + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{_chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + }) + + def _trace_headers() -> Dict[str, str]: + """生成 DataDog trace headers,模拟真实浏览器监控""" + trace_id = random.randint(10**17, 10**18 - 1) + parent_id = random.randint(10**17, 10**18 - 1) + tp = f"00-{uuid.uuid4().hex}-{format(parent_id, '016x')}-01" + return { + "traceparent": tp, "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": str(trace_id), "x-datadog-parent-id": str(parent_id), + } + pool_relay_url = _pool_relay_url_from_fetch_url(str(pool_cfg.get("api_url") or "")) + pool_relay_enabled = bool(pool_cfg["enabled"] and pool_relay_url) + relay_cookie_jar: Dict[str, str] = {} + pool_relay_api_key = str(pool_cfg.get("api_key") or DEFAULT_PROXY_POOL_API_KEY).strip() or DEFAULT_PROXY_POOL_API_KEY + pool_relay_country = str(pool_cfg.get("country") or DEFAULT_PROXY_POOL_COUNTRY).strip().upper() or DEFAULT_PROXY_POOL_COUNTRY + relay_fallback_warned = False + relay_bypass_openai_hosts = False + openai_relay_probe_done = False + mail_proxy_selector = None if pool_relay_enabled else _next_proxy_value + mail_proxies_selector = None if pool_relay_enabled else _next_proxies + + def _fallback_proxies_for_relay_failure() -> Any: + if static_proxy: + return _to_proxies_dict(static_proxy) + return None + + def _target_host(target_url: str) -> str: + return str(urlparse(str(target_url or "")).hostname or "").strip().lower() + + def _is_openai_like_host(host: str) -> bool: + return bool(host) and (host.endswith("openai.com") or host.endswith("chatgpt.com")) + + def _should_bypass_relay_for_target(target_url: str) -> bool: + host = _target_host(target_url) + return relay_bypass_openai_hosts and _is_openai_like_host(host) + + def _warn_relay_fallback(reason: str, target_url: str) -> None: + nonlocal relay_fallback_warned, relay_bypass_openai_hosts + host = _target_host(target_url) or str(target_url or "?") + if _is_openai_like_host(host): + relay_bypass_openai_hosts = True + if relay_fallback_warned: + return + if static_proxy: + emitter.warn(f"代理池 relay 对 {host} 不可用,回退固定代理: {reason}", step="check_proxy") + else: + emitter.warn(f"代理池 relay 对 {host} 不可用,回退直连: {reason}", step="check_proxy") + relay_fallback_warned = True + + def _update_relay_cookie_jar(resp: Any) -> None: + try: + for k, v in (resp.cookies or {}).items(): + key = str(k or "").strip() + if key: + relay_cookie_jar[key] = str(v or "") + except Exception: + pass + set_cookie_values: list[str] = [] + try: + values = resp.headers.get_list("set-cookie") # type: ignore[attr-defined] + if values: + set_cookie_values.extend(str(v or "") for v in values if str(v or "").strip()) + except Exception: + pass + if not set_cookie_values: + try: + set_cookie_raw = str(resp.headers.get("set-cookie") or "") + if set_cookie_raw.strip(): + set_cookie_values.append(set_cookie_raw) + except Exception: + pass + for set_cookie_raw in set_cookie_values: + try: + parsed_cookie = SimpleCookie() + parsed_cookie.load(set_cookie_raw) + for k, morsel in parsed_cookie.items(): + key = str(k or "").strip() + if key: + relay_cookie_jar[key] = str(morsel.value or "") + except Exception: + pass + try: + for k, v in relay_cookie_jar.items(): + s.cookies.set(k, v) + except Exception: + pass + + def _request_via_pool_relay(method: str, target_url: str, **kwargs: Any): + if not pool_relay_enabled: + raise RuntimeError("代理池 relay 未启用") + relay_retries_override = kwargs.pop("_relay_retries", None) + relay_params = { + "api_key": pool_relay_api_key, + "url": str(target_url), + "method": str(method or "GET").upper(), + "country": pool_relay_country, + } + target_params = kwargs.pop("params", None) + if target_params: + query_text = urlencode(target_params, doseq=True) + if query_text: + separator = "&" if "?" in relay_params["url"] else "?" + relay_params["url"] = f"{relay_params['url']}{separator}{query_text}" + + headers = dict(kwargs.pop("headers", {}) or {}) + if relay_cookie_jar and not any(str(k).lower() == "cookie" for k in headers.keys()): + headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in relay_cookie_jar.items()) + kwargs.pop("proxies", None) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 20) + + method_upper = relay_params["method"] + retry_count = max( + 1, + int( + relay_retries_override + if relay_retries_override is not None + else (pool_cfg.get("relay_request_retries") or POOL_RELAY_REQUEST_RETRIES) + ), + ) + last_error = "" + for i in range(retry_count): + try: + resp = _call_with_http_fallback( + lambda relay_endpoint, **call_kwargs: requests.request(method_upper, relay_endpoint, **call_kwargs), + pool_relay_url, + params=relay_params, + headers=headers or None, + **kwargs, + ) + _update_relay_cookie_jar(resp) + if resp.status_code >= 500 or resp.status_code == 429: + last_error = f"HTTP {resp.status_code}" + if i < retry_count - 1: + time.sleep(min(0.4 * (i + 1), 1.2)) + continue + return resp + except Exception as exc: + last_error = str(exc) + if i < retry_count - 1: + time.sleep(min(0.4 * (i + 1), 1.2)) + raise RuntimeError(f"代理池 relay 请求失败: {last_error or 'unknown error'}") + + def _ensure_openai_relay_ready() -> None: + nonlocal openai_relay_probe_done + if not pool_relay_enabled or relay_bypass_openai_hosts or openai_relay_probe_done: + return + openai_relay_probe_done = True + probe_url = "https://auth.openai.com/" + try: + probe_resp = _request_via_pool_relay( + "GET", + probe_url, + timeout=5, + allow_redirects=False, + _relay_retries=1, + ) + status = int(probe_resp.status_code or 0) + if status < 200 or status >= 400: + raise RuntimeError(f"HTTP {status}") + emitter.info("代理池 relay OpenAI 预检通过", step="check_proxy") + except Exception as exc: + _warn_relay_fallback(f"{exc} (OpenAI 预检)", probe_url) + + def _session_get(url: str, **kwargs: Any): + if pool_relay_enabled and not _should_bypass_relay_for_target(url): + try: + relay_resp = _request_via_pool_relay("GET", url, **kwargs) + if relay_resp.status_code < 500 and relay_resp.status_code != 429: + return relay_resp + raise RuntimeError(f"HTTP {relay_resp.status_code}") + except Exception as exc: + _warn_relay_fallback(str(exc), url) + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(s.get, url, **kwargs) + if pool_relay_enabled and _should_bypass_relay_for_target(url): + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(s.get, url, **kwargs) + kwargs["proxies"] = _next_proxies() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 15) + return _call_with_http_fallback(s.get, url, **kwargs) + + def _session_post(url: str, **kwargs: Any): + if pool_relay_enabled and not _should_bypass_relay_for_target(url): + try: + relay_resp = _request_via_pool_relay("POST", url, **kwargs) + if relay_resp.status_code < 500 and relay_resp.status_code != 429: + return relay_resp + raise RuntimeError(f"HTTP {relay_resp.status_code}") + except Exception as exc: + _warn_relay_fallback(str(exc), url) + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(s.post, url, **kwargs) + if pool_relay_enabled and _should_bypass_relay_for_target(url): + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(s.post, url, **kwargs) + kwargs["proxies"] = _next_proxies() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("timeout", 15) + return _call_with_http_fallback(s.post, url, **kwargs) + + def _raw_get(url: str, **kwargs: Any): + if pool_relay_enabled and not _should_bypass_relay_for_target(url): + try: + relay_resp = _request_via_pool_relay("GET", url, **kwargs) + if relay_resp.status_code < 500 and relay_resp.status_code != 429: + return relay_resp + raise RuntimeError(f"HTTP {relay_resp.status_code}") + except Exception as exc: + _warn_relay_fallback(str(exc), url) + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(requests.get, url, **kwargs) + if pool_relay_enabled and _should_bypass_relay_for_target(url): + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(requests.get, url, **kwargs) + kwargs["proxies"] = _next_proxies() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("timeout", 15) + return _call_with_http_fallback(requests.get, url, **kwargs) + + def _raw_post(url: str, **kwargs: Any): + if pool_relay_enabled and not _should_bypass_relay_for_target(url): + try: + relay_resp = _request_via_pool_relay("POST", url, **kwargs) + if relay_resp.status_code < 500 and relay_resp.status_code != 429: + return relay_resp + raise RuntimeError(f"HTTP {relay_resp.status_code}") + except Exception as exc: + _warn_relay_fallback(str(exc), url) + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(requests.post, url, **kwargs) + if pool_relay_enabled and _should_bypass_relay_for_target(url): + kwargs["proxies"] = _fallback_proxies_for_relay_failure() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("timeout", 20) + return _call_with_http_fallback(requests.post, url, **kwargs) + kwargs["proxies"] = _next_proxies() + kwargs.setdefault("http_version", DEFAULT_HTTP_VERSION) + kwargs.setdefault("impersonate", "chrome") + kwargs.setdefault("timeout", 15) + return _call_with_http_fallback(requests.post, url, **kwargs) + + def _submit_callback_url_via_pool_relay( + *, + callback_url: str, + expected_state: str, + code_verifier: str, + redirect_uri: str = DEFAULT_REDIRECT_URI, + ) -> str: + cb = _parse_callback_url(callback_url) + if cb["error"]: + desc = cb["error_description"] + raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip()) + if not cb["code"]: + raise ValueError("callback url missing ?code=") + if not cb["state"]: + raise ValueError("callback url missing ?state=") + if cb["state"] != expected_state: + raise ValueError("state mismatch") + + token_resp = _request_via_pool_relay( + "POST", + TOKEN_URL, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + data=urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": cb["code"], + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + ), + timeout=30, + ) + if token_resp.status_code != 200: + raise RuntimeError( + f"token exchange failed: {token_resp.status_code}: {str(token_resp.text or '')[:240]}" + ) + try: + token_json = token_resp.json() + except Exception: + token_json = json.loads(str(token_resp.text or "{}")) + + return _build_token_result(token_json, account_password=account_password) + + def _stopped() -> bool: + return stop_event is not None and stop_event.is_set() + + try: + # ------- 步骤1:网络环境检查 ------- + emitter.info("正在检查网络环境...", step="check_proxy") + try: + trace_text = "" + relay_error = "" + relay_used = False + if pool_cfg["enabled"]: + try: + trace_text = _trace_via_pool_relay(pool_cfg) + relay_used = True + except Exception as e: + relay_error = str(e) + if static_proxy: + emitter.warn(f"代理池 relay 检查失败,回退固定代理: {relay_error}", step="check_proxy") + else: + emitter.warn(f"代理池 relay 检查失败,尝试直连代理: {relay_error}", step="check_proxy") + if not trace_text: + trace_resp = _session_get("https://cloudflare.com/cdn-cgi/trace", timeout=10) + trace_text = trace_resp.text + trace = trace_text + loc_re = re.search(r"^loc=(.+)$", trace, re.MULTILINE) + loc = loc_re.group(1) if loc_re else None + ip_re = re.search(r"^ip=(.+)$", trace, re.MULTILINE) + current_ip = ip_re.group(1).strip() if ip_re else "" + if relay_used: + emitter.info("代理池 relay 连通检查成功", step="check_proxy") + emitter.info(f"当前 IP 所在地: {loc}", step="check_proxy") + if current_ip: + emitter.info(f"当前出口 IP: {current_ip}", step="check_proxy") + if loc == "CN" or loc == "HK": + emitter.error("检查代理哦 — 所在地不支持 (CN/HK)", step="check_proxy") + return None + emitter.success("网络环境检查通过", step="check_proxy") + _ensure_openai_relay_ready() + except Exception as e: + emitter.error(f"网络连接检查失败: {e}", step="check_proxy") + return None + + if _stopped(): + return None + + # ------- 步骤2:创建临时邮箱 ------- + if mail_provider is not None: + emitter.info("正在创建临时邮箱...", step="create_email") + try: + email, dev_token = mail_provider.create_mailbox( + proxy=static_proxy, + proxy_selector=mail_proxy_selector, + ) + except TypeError: + email, dev_token = mail_provider.create_mailbox(proxy=static_proxy) + else: + emitter.info("正在创建 Mail.tm 临时邮箱...", step="create_email") + email, dev_token = get_email_and_token( + static_proxies, + emitter, + proxy_selector=mail_proxies_selector, + ) + if not email or not dev_token: + emitter.error("临时邮箱创建失败", step="create_email") + return None + emitter.success(f"临时邮箱创建成功: {email}", step="create_email") + + # 生成随机密码(密码注册流程需要) + _pw_chars = string.ascii_letters + string.digits + "!@#$%&*" + account_password = "".join(secrets.choice(_pw_chars) for _ in range(16)) + + if _stopped(): + return None + + # ------- 步骤3:通过 chatgpt.com 建立注册会话 ------- + emitter.info("正在访问 ChatGPT 首页...", step="oauth_init") + _chatgpt_base = "https://chatgpt.com" + + # 3a: 访问首页,获取 cookies + _session_get(f"{_chatgpt_base}/", timeout=20) + + # 3b: 获取 CSRF Token + csrf_resp = _session_get( + f"{_chatgpt_base}/api/auth/csrf", + headers={"Accept": "application/json", "Referer": f"{_chatgpt_base}/"}, + timeout=15, + ) + try: + csrf_token = csrf_resp.json().get("csrfToken", "") + except Exception: + csrf_token = "" + if not csrf_token: + emitter.error("获取 CSRF Token 失败", step="oauth_init") + return None + + # 3c: 生成 Device ID + did = s.cookies.get("oai-did") or relay_cookie_jar.get("oai-did") or "" + if not did: + did = str(uuid.uuid4()) + relay_cookie_jar["oai-did"] = did + try: + s.cookies.set("oai-did", did, domain="chatgpt.com") + except Exception: + try: + s.cookies.set("oai-did", did) + except Exception: + pass + + # 3d: Signin 请求,获取 authorize URL + auth_session_id = str(uuid.uuid4()) + signin_params = urllib.parse.urlencode({ + "prompt": "login", + "ext-oai-did": did, + "auth_session_logging_id": auth_session_id, + "screen_hint": "login_or_signup", + "login_hint": email, + }) + signin_resp = _session_post( + f"{_chatgpt_base}/api/auth/signin/openai?{signin_params}", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "Referer": f"{_chatgpt_base}/", + "Origin": _chatgpt_base, + }, + data=urllib.parse.urlencode({ + "callbackUrl": f"{_chatgpt_base}/", + "csrfToken": csrf_token, + "json": "true", + }), + timeout=20, + ) + try: + authorize_url = signin_resp.json().get("url", "") + except Exception: + authorize_url = "" + if not authorize_url: + emitter.error( + f"Signin 获取授权链接失败({signin_resp.status_code}): {str(signin_resp.text or '')[:220]}", + step="oauth_init", + ) + return None + emitter.info(f"OAuth 初始化状态: {signin_resp.status_code}", step="oauth_init") + + # 3e: 跟随 authorize 重定向,建立 auth.openai.com 会话 + auth_resp = _session_get( + authorize_url, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": f"{_chatgpt_base}/", + "Upgrade-Insecure-Requests": "1", + }, + timeout=20, + ) + final_url = str(auth_resp.url) if hasattr(auth_resp, "url") else "" + emitter.info(f"Authorize 重定向完成: {final_url[:120]}", step="oauth_init") + emitter.info(f"Device ID: {did}", step="oauth_init") + + if _stopped(): + return None + + # ------- 步骤4+5:密码注册(合并旧步骤4 Sentinel + 旧步骤5 注册) ------- + time.sleep(random.uniform(0.5, 1.0)) + emitter.info("正在提交注册表单(密码模式)...", step="signup") + _reg_headers = { + "referer": "https://auth.openai.com/create-account/password", + "accept": "application/json", + "content-type": "application/json", + "origin": "https://auth.openai.com", + } + _reg_headers.update(_trace_headers()) + signup_resp = _session_post( + "https://auth.openai.com/api/accounts/user/register", + headers=_reg_headers, + json={"username": email, "password": account_password}, + ) + emitter.info(f"注册表单提交状态: {signup_resp.status_code}", step="signup") + if signup_resp.status_code != 200: + emitter.error( + f"注册表单提交失败(状态码 {signup_resp.status_code}): {str(signup_resp.text or '')[:220]}", + step="signup", + ) + return None + + # ------- 步骤6:发送 OTP 验证码 ------- + time.sleep(random.uniform(0.3, 0.8)) + emitter.info("正在发送邮箱验证码...", step="send_otp") + otp_resp = _session_get( + "https://auth.openai.com/api/accounts/email-otp/send", + headers={ + "referer": "https://auth.openai.com/create-account/password", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "upgrade-insecure-requests": "1", + }, + ) + emitter.info(f"验证码发送状态: {otp_resp.status_code}", step="send_otp") + if otp_resp.status_code == 409: + emitter.warn(f"send_otp 409 响应: {str(otp_resp.text or '')[:220]}", step="send_otp") + + if otp_resp.status_code != 200: + emitter.error(f"验证码发送失败(状态码 {otp_resp.status_code}): {str(otp_resp.text or '')[:220]}", step="send_otp") + return None + + if _stopped(): + return None + + # ------- 步骤7:轮询邮箱拿验证码 ------- + if mail_provider is not None: + try: + code = mail_provider.wait_for_otp( + dev_token, + email, + proxy=static_proxy, + proxy_selector=mail_proxy_selector, + stop_event=stop_event, + ) + except TypeError: + code = mail_provider.wait_for_otp( + dev_token, + email, + proxy=static_proxy, + stop_event=stop_event, + ) + else: + code = get_oai_code( + dev_token, + email, + static_proxies, + emitter, + stop_event, + proxy_selector=mail_proxies_selector, + ) + if not code: + return None + + if _stopped(): + return None + + # ------- 步骤8:提交验证码 ------- + time.sleep(random.uniform(0.3, 0.8)) + emitter.info("正在验证 OTP...", step="verify_otp") + _otp_headers = { + "referer": "https://auth.openai.com/email-verification", + "accept": "application/json", + "content-type": "application/json", + "origin": "https://auth.openai.com", + } + _otp_headers.update(_trace_headers()) + code_resp = _session_post( + "https://auth.openai.com/api/accounts/email-otp/validate", + headers=_otp_headers, + json={"code": code}, + ) + emitter.info(f"验证码校验状态: {code_resp.status_code}", step="verify_otp") + if code_resp.status_code != 200: + emitter.error( + f"验证码校验失败(状态码 {code_resp.status_code}): {str(code_resp.text or '')[:220]}", + step="verify_otp", + ) + return None + + if _stopped(): + return None + + # ------- 步骤9:创建账户 ------- + time.sleep(random.uniform(0.5, 1.5)) + emitter.info("正在创建账户信息...", step="create_account") + _rand_first = random.choice([ + "James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Sophia", + "Lucas", "Mia", "Mason", "Isabella", "Logan", "Charlotte", "Alexander", + "Amelia", "Benjamin", "Harper", "William", "Evelyn", "Henry", "Abigail", + ]) + _rand_last = random.choice([ + "Smith", "Johnson", "Brown", "Davis", "Wilson", "Moore", "Taylor", + "Clark", "Hall", "Young", "Anderson", "Thomas", "Jackson", "White", + ]) + _rand_name = f"{_rand_first} {_rand_last}" + _rand_bday = f"{random.randint(1985, 2002)}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}" + _ca_headers = { + "referer": "https://auth.openai.com/about-you", + "accept": "application/json", + "content-type": "application/json", + "origin": "https://auth.openai.com", + } + _ca_headers.update(_trace_headers()) + create_account_resp = _session_post( + "https://auth.openai.com/api/accounts/create_account", + headers=_ca_headers, + json={"name": _rand_name, "birthdate": _rand_bday}, + ) + create_account_status = create_account_resp.status_code + emitter.info(f"账户创建状态: {create_account_status}", step="create_account") + + if create_account_status != 200: + emitter.error(create_account_resp.text, step="create_account") + return None + + emitter.success("账户创建成功!", step="create_account") + + # 跟随 callback URL 完成注册流程 + try: + _ca_data = create_account_resp.json() if create_account_resp.text else {} + except Exception: + _ca_data = {} + _callback_url = ( + _ca_data.get("continue_url") + or _ca_data.get("url") + or _ca_data.get("redirect_url") + or "" + ) + if _callback_url: + emitter.info("正在完成注册回调...", step="create_account") + _session_get( + _callback_url, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Upgrade-Insecure-Requests": "1", + }, + timeout=20, + ) + + if _stopped(): + return None + + # ------- 步骤10+11:完整 OAuth 登录流程获取 Token ------- + emitter.info("正在通过 OAuth 登录获取 Token...", step="get_token") + + # 确保 auth 域也有 oai-did cookie + try: + s.cookies.set("oai-did", did, domain=".auth.openai.com") + s.cookies.set("oai-did", did, domain="auth.openai.com") + except Exception: + pass + + # 10a: 生成 PKCE 参数和 authorize URL + oauth = generate_oauth_url() + + # 10b: Sentinel PoW token 生成器(纯 Python) + class _SentinelGen: + MAX_ATTEMPTS = 500000 + ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + def __init__(self, dev_id, ua): + self.dev_id = dev_id + self.ua = ua + self.req_seed = str(random.random()) + self.sid = str(uuid.uuid4()) + @staticmethod + def _fnv1a(text): + h = 2166136261 + for ch in text: + h ^= ord(ch); h = (h * 16777619) & 0xFFFFFFFF + h ^= (h >> 16); h = (h * 2246822507) & 0xFFFFFFFF + h ^= (h >> 13); h = (h * 3266489909) & 0xFFFFFFFF + h ^= (h >> 16); return format(h & 0xFFFFFFFF, "08x") + def _cfg(self): + now_s = time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()) + perf = random.uniform(1000, 50000) + return ["1920x1080", now_s, 4294705152, random.random(), self.ua, + "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js", + None, None, "en-US", "en-US,en", random.random(), + random.choice(["vendorSub","productSub","hardwareConcurrency","cookieEnabled"]) + "-undefined", + random.choice(["location","URL","compatMode"]), + random.choice(["Object","Function","Array","Number"]), + perf, self.sid, "", random.choice([4,8,12,16]), time.time()*1000 - perf] + @staticmethod + def _b64(data): + return base64.b64encode(json.dumps(data, separators=(",",":")).encode()).decode() + def _solve(self, seed, diff, cfg, nonce): + cfg[3] = nonce; cfg[9] = round((time.time() - self._t0) * 1000) + d = self._b64(cfg); h = self._fnv1a(seed + d) + return (d + "~S") if h[:len(diff)] <= diff else None + def gen_token(self, seed=None, diff="0"): + seed = seed or self.req_seed; self._t0 = time.time(); cfg = self._cfg() + for i in range(self.MAX_ATTEMPTS): + r = self._solve(seed, str(diff), cfg, i) + if r: return "gAAAAAB" + r + return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None)) + def gen_req_token(self): + cfg = self._cfg(); cfg[3] = 1; cfg[9] = round(random.uniform(5, 50)) + return "gAAAAAC" + self._b64(cfg) + + _sentinel = _SentinelGen(did, _chrome_ua) + + def _build_sentinel(flow): + req_body = json.dumps({"p": _sentinel.gen_req_token(), "id": did, "flow": flow}) + sen_resp = _session_post( + "https://sentinel.openai.com/backend-api/sentinel/req", + headers={ + "Content-Type": "text/plain;charset=UTF-8", + "Origin": "https://sentinel.openai.com", + "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html", + }, + data=req_body, + ) + if sen_resp.status_code != 200: + return None + try: + ch = sen_resp.json() + except Exception: + return None + c_val = ch.get("token", "") + if not c_val: + return None + pow_d = ch.get("proofofwork") or {} + if pow_d.get("required") and pow_d.get("seed"): + p_val = _sentinel.gen_token(seed=pow_d["seed"], diff=pow_d.get("difficulty", "0")) + else: + p_val = _sentinel.gen_req_token() + return json.dumps({"p": p_val, "t": "", "c": c_val, "id": did, "flow": flow}, separators=(",",":")) + + def _oauth_headers(referer): + h = {"Accept": "application/json", "Content-Type": "application/json", + "Origin": "https://auth.openai.com", "Referer": referer, "oai-device-id": did} + h.update(_trace_headers()) + return h + + # 10c: GET /oauth/authorize — 建立 OAuth 会话 + emitter.info("OAuth 1/5: 初始化授权...", step="get_token") + _session_get(oauth.auth_url, timeout=30) + + if _stopped(): + return None + + # 10d: POST authorize/continue — 提交邮箱 + emitter.info("OAuth 2/5: 提交邮箱...", step="get_token") + _sen_ac = _build_sentinel("authorize_continue") + if not _sen_ac: + emitter.error("Sentinel token (authorize_continue) 获取失败", step="get_token") + return None + _ac_headers = _oauth_headers("https://auth.openai.com/log-in") + _ac_headers["openai-sentinel-token"] = _sen_ac + _ac_resp = _session_post( + "https://auth.openai.com/api/accounts/authorize/continue", + headers=_ac_headers, + json={"username": {"kind": "email", "value": email}}, + ) + emitter.info(f"authorize/continue -> {_ac_resp.status_code}", step="get_token") + if _ac_resp.status_code != 200: + emitter.error(f"authorize/continue 失败: {str(_ac_resp.text or '')[:200]}", step="get_token") + return None + + if _stopped(): + return None + + # 10e: POST password/verify — 提交密码 + emitter.info("OAuth 3/5: 验证密码...", step="get_token") + _sen_pw = _build_sentinel("password_verify") + if not _sen_pw: + emitter.error("Sentinel token (password_verify) 获取失败", step="get_token") + return None + _pw_headers = _oauth_headers("https://auth.openai.com/log-in/password") + _pw_headers["openai-sentinel-token"] = _sen_pw + _pw_resp = _session_post( + "https://auth.openai.com/api/accounts/password/verify", + headers=_pw_headers, + json={"password": account_password}, + ) + emitter.info(f"password/verify -> {_pw_resp.status_code}", step="get_token") + if _pw_resp.status_code != 200: + emitter.error(f"password/verify 失败: {str(_pw_resp.text or '')[:200]}", step="get_token") + return None + + try: + _pw_data = _pw_resp.json() + except Exception: + _pw_data = {} + _consent_url = str(_pw_data.get("continue_url") or "").strip() + _page_type = str((_pw_data.get("page") or {}).get("type", "")).strip() + emitter.info(f"password/verify page={_page_type or '-'} next={(_consent_url or '-')[:140]}", step="get_token") + + # OAuth 阶段可能需要第二次邮箱 OTP 验证 + _need_oauth_otp = ( + _page_type == "email_otp_verification" + or "email-verification" in (_consent_url or "") + or "email-otp" in (_consent_url or "") + ) + if _need_oauth_otp: + emitter.info("OAuth 需要邮箱 OTP 验证...", step="get_token") + if not dev_token or mail_provider is None: + emitter.error("OAuth OTP 验证需要邮箱 token,但不可用", step="get_token") + return None + + _otp_ok = False + _otp_deadline = time.time() + 120 + _tried_codes: set = set() + while time.time() < _otp_deadline and not _otp_ok: + if _stopped(): + return None + try: + _otp_code2 = mail_provider.wait_for_otp( + dev_token, email, proxy=static_proxy, + proxy_selector=mail_proxy_selector, stop_event=stop_event, + ) + except TypeError: + _otp_code2 = mail_provider.wait_for_otp( + dev_token, email, proxy=static_proxy, stop_event=stop_event, + ) + if not _otp_code2 or _otp_code2 in _tried_codes: + time.sleep(2) + continue + _tried_codes.add(_otp_code2) + emitter.info(f"OAuth OTP 尝试: {_otp_code2}", step="get_token") + _otp2_h = _oauth_headers("https://auth.openai.com/email-verification") + _otp2_resp = _session_post( + "https://auth.openai.com/api/accounts/email-otp/validate", + headers=_otp2_h, + json={"code": _otp_code2}, + ) + emitter.info(f"OAuth OTP validate -> {_otp2_resp.status_code}", step="get_token") + if _otp2_resp.status_code == 200: + try: + _otp2_data = _otp2_resp.json() + except Exception: + _otp2_data = {} + _consent_url = str(_otp2_data.get("continue_url") or "").strip() or _consent_url + _page_type = str((_otp2_data.get("page") or {}).get("type", "")).strip() or _page_type + emitter.info(f"OAuth OTP 验证通过 page={_page_type or '-'} next={(_consent_url or '-')[:140]}", step="get_token") + _otp_ok = True + break + time.sleep(2) + + if not _otp_ok: + emitter.error(f"OAuth OTP 验证失败,已尝试 {len(_tried_codes)} 个验证码", step="get_token") + return None + + if _stopped(): + return None + + # 10f: Workspace/consent/org 处理 + 提取 code(对标参考代码) + _AUTH = "https://auth.openai.com" + + def _extract_code(url): + if not url or "code=" not in url: + return None + try: + return urllib.parse.parse_qs(urllib.parse.urlparse(url).query).get("code", [None])[0] + except Exception: + return None + + def _follow_for_code(start_url): + """手动跟随重定向链,逐步检查 Location 中是否含 code""" + url = start_url + for _ in range(12): + try: + r = _session_get(url, allow_redirects=False, timeout=15) + except Exception as e: + # curl_cffi 连接 localhost 会抛异常,URL 可能在异常信息中 + m = re.search(r'(https?://localhost[^\s\'"]+)', str(e)) + if m: + return _extract_code(m.group(1)) + return None + if r.status_code in (301, 302, 303, 307, 308): + loc = r.headers.get("Location", "") + if not loc: + break + next_url = urllib.parse.urljoin(url, loc) + c = _extract_code(next_url) + if c: + return c + url = next_url + continue + break + return None + + def _ws_org_select(consent_ref): + """完整的 workspace + organization 选择流程""" + # 解析 session cookie 获取 workspace + _ck_data = None + _auth_ck = s.cookies.get("oai-client-auth-session") or relay_cookie_jar.get("oai-client-auth-session") or "" + if _auth_ck: + try: + _ck_data = _decode_jwt_segment(_auth_ck.split(".")[0]) + except Exception: + pass + if not _ck_data: + emitter.info("无法解码 auth session cookie", step="workspace") + return None + + _ws_list = _ck_data.get("workspaces") or [] + _ws_id = str((_ws_list[0] or {}).get("id") or "").strip() if _ws_list else "" + if not _ws_id: + emitter.info("session 中没有 workspace", step="workspace") + return None + + emitter.info(f"选择 workspace: {_ws_id}", step="workspace") + _ws_h = _oauth_headers(consent_ref) + _ws_resp = _session_post( + f"{_AUTH}/api/accounts/workspace/select", + headers=_ws_h, + json={"workspace_id": _ws_id}, + allow_redirects=False, + ) + emitter.info(f"workspace/select -> {_ws_resp.status_code}", step="workspace") + + # 如果是重定向,直接提取 code + if _ws_resp.status_code in (301, 302, 303, 307, 308): + loc = _ws_resp.headers.get("Location", "") + if loc.startswith("/"): + loc = f"{_AUTH}{loc}" + c = _extract_code(loc) + if c: + return c + return _follow_for_code(loc) + + if _ws_resp.status_code != 200: + return None + + try: + _ws_data = _ws_resp.json() + except Exception: + return None + + _ws_next = str(_ws_data.get("continue_url") or "").strip() + _ws_page = str((_ws_data.get("page") or {}).get("type", "")) + _orgs = (_ws_data.get("data") or {}).get("orgs") or [] + emitter.info(f"workspace/select page={_ws_page or '-'} orgs={len(_orgs)} next={(_ws_next or '-')[:140]}", step="workspace") + + # Organization 选择 + if _orgs: + _org_id = (_orgs[0] or {}).get("id") + _projects = (_orgs[0] or {}).get("projects") or [] + _proj_id = (_projects[0] or {}).get("id") if _projects else None + if _org_id: + _org_body = {"org_id": _org_id} + if _proj_id: + _org_body["project_id"] = _proj_id + _org_ref = _ws_next if _ws_next and _ws_next.startswith("http") else f"{_AUTH}{_ws_next}" if _ws_next else consent_ref + _org_h = _oauth_headers(_org_ref) + emitter.info(f"选择 organization: {_org_id}", step="workspace") + _org_resp = _session_post( + f"{_AUTH}/api/accounts/organization/select", + headers=_org_h, + json=_org_body, + allow_redirects=False, + ) + emitter.info(f"organization/select -> {_org_resp.status_code}", step="workspace") + + if _org_resp.status_code in (301, 302, 303, 307, 308): + loc = _org_resp.headers.get("Location", "") + if loc.startswith("/"): + loc = f"{_AUTH}{loc}" + c = _extract_code(loc) + if c: + return c + return _follow_for_code(loc) + + if _org_resp.status_code == 200: + try: + _org_data = _org_resp.json() + except Exception: + _org_data = {} + _org_next = str(_org_data.get("continue_url") or "").strip() + if _org_next: + if _org_next.startswith("/"): + _org_next = f"{_AUTH}{_org_next}" + c = _extract_code(_org_next) + if c: + return c + return _follow_for_code(_org_next) + + # 无 org 或 org 选择后仍无 code,跟随 ws_next + if _ws_next: + if _ws_next.startswith("/"): + _ws_next = f"{_AUTH}{_ws_next}" + c = _extract_code(_ws_next) + if c: + return c + return _follow_for_code(_ws_next) + + return None + + _code = None + + # 规范化 consent_url + if _consent_url and _consent_url.startswith("/"): + _consent_url = f"{_AUTH}{_consent_url}" + if not _consent_url and "consent" in _page_type: + _consent_url = f"{_AUTH}/sign-in-with-chatgpt/codex/consent" + + # 先从 URL 直接提取 + if _consent_url: + _code = _extract_code(_consent_url) + + # 跟随 consent_url 重定向 + if not _code and _consent_url: + emitter.info("OAuth 4/5: 跟随 consent URL...", step="get_token") + _code = _follow_for_code(_consent_url) + + # workspace + organization 选择 + _consent_hint = any(kw in (_consent_url or "") for kw in ["consent", "workspace", "organization", "sign-in-with"]) + _consent_hint = _consent_hint or any(kw in _page_type for kw in ["consent", "organization"]) + if not _code and (_consent_hint or not _consent_url): + emitter.info("OAuth 4/5: 处理 workspace/org...", step="workspace") + _ws_ref = _consent_url or f"{_AUTH}/sign-in-with-chatgpt/codex/consent" + _code = _ws_org_select(_ws_ref) + + # 回退 + if not _code: + emitter.info("OAuth 4/5: 回退 consent 路径...", step="get_token") + _code = _ws_org_select(f"{_AUTH}/sign-in-with-chatgpt/codex/consent") + if not _code: + _code = _follow_for_code(f"{_AUTH}/sign-in-with-chatgpt/codex/consent") + + if not _code: + emitter.error("未能获取 OAuth authorization code", step="get_token") + try: s.close() + except: pass + return None + + # 10g: POST /oauth/token — 用 code 换取 Token + emitter.info("OAuth 5/5: 交换 Token...", step="get_token") + _token_resp = _session_post( + TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + data=urllib.parse.urlencode({ + "grant_type": "authorization_code", + "code": _code, + "redirect_uri": oauth.redirect_uri, + "client_id": CLIENT_ID, + "code_verifier": oauth.code_verifier, + }), + timeout=30, + ) + if _token_resp.status_code != 200: + emitter.error(f"Token 交换失败({_token_resp.status_code}): {str(_token_resp.text or '')[:200]}", step="get_token") + try: s.close() + except: pass + return None + + try: + _token_json = _token_resp.json() + except Exception: + _token_json = json.loads(str(_token_resp.text or "{}")) + + emitter.success("Token 获取成功!", step="get_token") + try: s.close() + except: pass + return _build_token_result(_token_json, account_password=account_password) + + except Exception as e: + emitter.error(f"运行时发生错误: {e}", step="runtime") + try: s.close() + except: pass + return None + +# ========================================== +# CLI 入口(兼容直接运行) +# ========================================== + + +def main() -> None: + parser = argparse.ArgumentParser(description="OpenAI 账号池编排器脚本") + parser.add_argument( + "--proxy", default=None, help="代理地址,如 http://127.0.0.1:7897" + ) + parser.add_argument("--once", action="store_true", help="只运行一次") + parser.add_argument("--sleep-min", type=int, default=5, help="循环模式最短等待秒数") + parser.add_argument( + "--sleep-max", type=int, default=30, help="循环模式最长等待秒数" + ) + args = parser.parse_args() + + sleep_min = max(1, args.sleep_min) + sleep_max = max(sleep_min, args.sleep_max) + + os.makedirs(TOKENS_DIR, exist_ok=True) + + try: + from cli_app.support import load_sync_config + + sync_cfg = load_sync_config() + except Exception: + sync_cfg = {} + + cpa_base_url = str(sync_cfg.get("cpa_base_url") or "").strip() + cpa_token = str(sync_cfg.get("cpa_token") or "").strip() + + pool_maintainer = None + if cpa_base_url and cpa_token: + try: + from .pool_maintainer import PoolMaintainer + pool_maintainer = PoolMaintainer( + cpa_base_url=cpa_base_url, + cpa_token=cpa_token, + ) + except Exception as e: + print(f"[-] 初始化 PoolMaintainer 失败: {e}") + + count = 0 + print("[Info] OpenAI 账号池编排器 - CLI 模式") + + while True: + count += 1 + print( + f"\n[{datetime.now().strftime('%H:%M:%S')}] >>> 开始第 {count} 次注册流程 <<<" + ) + + try: + token_json = run(args.proxy) + + if token_json: + try: + t_data = json.loads(token_json) + fname_email = t_data.get("email", "unknown").replace("@", "_") + except Exception: + fname_email = "unknown" + t_data = {} + + file_name = f"token_{fname_email}_{time.time_ns()}.json" + file_path = os.path.join(TOKENS_DIR, file_name) + + _write_text_atomic(file_path, token_json) + + print(f"[*] 成功! Token 已保存至: {file_path}") + + if pool_maintainer and t_data: + print(f"[*] 正在尝试上传到 CPA...") + try: + cpa_ok = pool_maintainer.upload_token(file_name, t_data, proxy=args.proxy or "") + upload_email = t_data.get('email', fname_email) + if cpa_ok: + print(f"[+] CPA 上传成功: {upload_email}") + else: + print(f"[-] CPA 上传失败: {upload_email}") + except Exception as e: + print(f"[-] CPA 上传抛出异常: {e}") + else: + print("[-] 本次注册失败。") + + except Exception as e: + print(f"[Error] 发生未捕获异常: {e}") + + if args.once: + break + + wait_time = random.randint(sleep_min, sleep_max) + print(f"[*] 休息 {wait_time} 秒...") + time.sleep(wait_time) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c057e79 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f61879e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +curl-cffi>=0.6 +aiohttp>=3.9 +requests>=2.31 diff --git a/run.py b/run.py new file mode 100644 index 0000000..0ac378a --- /dev/null +++ b/run.py @@ -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:]))) diff --git a/support.py b/support.py new file mode 100644 index 0000000..90290ef --- /dev/null +++ b/support.py @@ -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] + "..."}