Files
gpt/account_manager.py
T
432539 fef45134d5 Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、
拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能
不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
2026-08-05 14:54:32 +08:00

2684 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号管理(Account Management)—— 从 auto(Node) 项目移植的 `plus`(GPT 管理) 功能。
本模块是「加法式」的:仅新增一个 Flask Blueprint,挂到主 appapp.register_blueprint)。
不改动任何既有功能。页面地址 /account-manager/API 前缀 /api/plus。
数据存储:card-binding/data/plus.json(记录形状与 auto/data/plus.json 一致:
email, password, clientId, refreshToken, note, createdAt,拿 RT 后追加
codexRt, codexClientId, codexAccessToken, codexIdToken, codexStatus, banned, banReason)。
两类操作的实现:
纯 Python(列表/导入/删除/删封号/导出)—— 忠实复刻 Node 的纯数据逻辑;
Node 子进程(查收 inbox / 批量拿 RT)—— 直接复用 auto 的 get-rt.js / plus-mailbox.js
经 account-manager/node/*.mjs 包装,用 `node <script>` + stdin(JSON) 调用,mirror
项目里既有的“Python 调子进程”做法。
"""
from __future__ import annotations
import base64
import json
import os
import random
import re
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
try:
import httpx
except Exception: # pragma: no cover - httpx ships with the server interpreter
httpx = None
from flask import Blueprint, Response, jsonify, render_template, request
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
PLUS_FILE = DATA_DIR / "plus.json"
NODE_DIR = ROOT / "account-manager" / "node"
CONFIG_DIR = ROOT / "account-manager" / "config"
SETTINGS_FILE = CONFIG_DIR / "settings.json"
PROXY_FILE = CONFIG_DIR / "proxies.json"
MAILBOX_FILE = CONFIG_DIR / "mailbox.json"
# Node 可执行文件:默认走 PATH 上的 node,可用环境变量覆盖。
NODE_BIN = os.getenv("ACCOUNT_MGR_NODE", "").strip() or os.getenv("NODE_BIN", "").strip() or "node"
INBOX_RUNNER = str(NODE_DIR / "inbox_runner.mjs")
RT_RUNNER = str(NODE_DIR / "rt_runner.mjs")
REG_RUNNER = str(NODE_DIR / "reg_runner.mjs")
# 浏览器注册(CloakBrowser 隐身 Chromiumrunner —— 与 REG_RUNNERHTTP 协议注册)并列,
# 由「注册方式」决定调用哪一个;两者输入/输出协议一致,故批量编排逻辑可共用。
CLOAK_REG_RUNNER = str(NODE_DIR / "cloak_reg_runner.mjs")
REFETCH_RUNNER = str(NODE_DIR / "refetch_runner.mjs")
# 注册子进程回读邮箱验证码用的内部地址(主 app 端口)
OTP_INTERNAL_BASE = os.getenv("ACCOUNT_MGR_BASE", "").strip() or "http://127.0.0.1:5088"
# 实时套餐校验(accounts/check)需 curl_cffi(firefox144) 绕过 Cloudflare;主程序解释器 curl_cffi 版本可能过旧,
# 故用 .venv 的 python 以子进程执行 plan_check_session.py(与 card_bind_session.py 同源)。
def _default_venv_python() -> str:
venv_dir = ROOT.parent / ".venv"
candidates = (
venv_dir / "Scripts" / "python.exe", # Windows
venv_dir / "bin" / "python", # Linux/macOS
)
return next((str(candidate) for candidate in candidates if candidate.exists()), sys.executable)
PY_BIN = os.getenv("PAY153_PYTHON", "").strip() or _default_venv_python()
PLAN_CHECK_SCRIPT = str(ROOT / "plan_check_session.py")
SUB2API_DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
bp = Blueprint("account_manager", __name__)
_plus_lock = threading.RLock()
# 批量拿 RT 运行态(mirror server.js 的 rtRunning + pushLog 日志环)
_rt_state = {
"running": False,
"total": 0,
"done": 0,
"ok": 0,
"banned": 0,
"fail": 0,
"logs": [], # [{ ts, level, msg, account }]
"started_at": 0,
"finished_at": 0,
}
_rt_lock = threading.RLock()
_MAX_LOGS = 500
# 协议注册运行态
_reg_state = {
"running": False,
"stop": False,
"total": 0,
"done": 0,
"ok": 0,
"fail": 0,
"logs": [],
"started_at": 0,
"finished_at": 0,
}
_reg_lock = threading.RLock()
# ---------- JSON 存储 ----------
def load_plus() -> list:
with _plus_lock:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not PLUS_FILE.exists():
return []
try:
data = json.loads(PLUS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def save_plus(items: list) -> None:
with _plus_lock:
DATA_DIR.mkdir(parents=True, exist_ok=True)
PLUS_FILE.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
def load_settings() -> dict:
try:
return json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) or {}
except Exception:
return {}
def load_proxies() -> list:
try:
data = json.loads(PROXY_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
# ---------- 解析导入格式(忠实复刻 plus-mailbox.js parsePlus----------
# 每行:email----password----clientId----refreshToken(兼容制表符 / | / 空白分隔)
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
def parse_plus(text: str) -> list:
out: list = []
seen: set = set()
for raw in str(text or "").split("\n"):
line = raw.replace("\r", "").strip()
if not line:
continue
if "----" in line:
parts = line.split("----")
elif "\t" in line:
parts = line.split("\t")
elif "|" in line:
parts = line.split("|")
else:
parts = re.split(r"\s+", line)
parts = [s.strip() for s in parts if s.strip()]
if len(parts) < 4:
continue
email, password, client_id, refresh_token = parts[0], parts[1], parts[2], parts[3]
if not _EMAIL_RE.match(email):
continue
key = email.lower()
if key in seen:
continue
seen.add(key)
out.append({"email": email, "password": password, "clientId": client_id, "refreshToken": refresh_token})
return out
# ---------- 页面 ----------
@bp.get("/account-manager/")
def account_manager_page():
return render_template("account-manager.html")
# ---------- GET /api/plus ----------
@bp.get("/api/plus")
def api_plus_list():
return jsonify({"accounts": load_plus()})
# ---------- POST /api/plus/import ----------
@bp.post("/api/plus/import")
def api_plus_import():
body = request.get_json(silent=True) or {}
parsed = parse_plus(body.get("text") or "")
with _plus_lock:
items = load_plus()
by_email = {a.get("email", "").lower(): a for a in items}
added = 0
updated = 0
for item in parsed:
key = item["email"].lower()
existing = by_email.get(key)
if existing:
changed = False
for f in ("password", "clientId", "refreshToken"):
if existing.get(f) != item.get(f):
existing[f] = item[f]
changed = True
if changed:
updated += 1
else:
rec = {
"email": item["email"],
"password": item["password"],
"clientId": item["clientId"],
"refreshToken": item["refreshToken"],
"note": "",
"createdAt": int(time.time() * 1000),
}
items.append(rec)
by_email[key] = rec
added += 1
save_plus(items)
total = len(items)
return jsonify({"added": added, "updated": updated, "parsed": len(parsed), "total": total})
# ---------- DELETE /api/plus ----------
# 按 emails 删除;「批量删除封号」由前端收集封号邮箱后走同一入口(与 Node 一致)。
@bp.delete("/api/plus")
def api_plus_delete():
body = request.get_json(silent=True) or {}
emails = body.get("emails") or []
target = {str(e).lower() for e in emails}
with _plus_lock:
items = load_plus()
before = len(items)
items = [a for a in items if a.get("email", "").lower() not in target]
save_plus(items)
total = len(items)
return jsonify({"removed": before - total, "total": total})
# ---------- POST /api/plus/inbox(查收,Node 子进程)----------
@bp.post("/api/plus/inbox")
def api_plus_inbox():
body = request.get_json(silent=True) or {}
email = str(body.get("email") or "")
items = load_plus()
acc = next((a for a in items if a.get("email", "").lower() == email.lower()), None)
if not acc:
return jsonify({"error": "账号不存在"}), 404
payload = {
"email": acc.get("email"),
"clientId": acc.get("clientId"),
"refreshToken": acc.get("refreshToken"),
}
try:
proc = subprocess.run(
[NODE_BIN, INBOX_RUNNER],
input=json.dumps(payload),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(NODE_DIR),
timeout=90,
)
except FileNotFoundError:
return jsonify({"error": f"未找到 Node 可执行文件:{NODE_BIN}"}), 500
except subprocess.TimeoutExpired:
return jsonify({"error": "查收超时"}), 504
out = (proc.stdout or "").strip()
if not out:
return jsonify({"error": f"查收进程无输出(stderr: {(proc.stderr or '').strip()[:300]}"}), 502
try:
result = json.loads(out)
except Exception:
return jsonify({"error": f"查收结果解析失败:{out[:300]}"}), 502
if not result.get("ok"):
status = int(result.get("status") or 502)
return jsonify({"error": result.get("error") or "查收失败"}), (401 if status == 401 else 502)
return jsonify({"address": result.get("address"), "via": result.get("via"), "messages": result.get("messages") or []})
# ---------- 拿 RT 编排(mirror server.js runGetRtBatch----------
def _rt_log(level: str, msg: str, account: str = "") -> None:
entry = {"ts": int(time.time() * 1000), "level": level, "msg": str(msg), "account": account or ""}
with _rt_lock:
_rt_state["logs"].append(entry)
if len(_rt_state["logs"]) > _MAX_LOGS:
del _rt_state["logs"][: len(_rt_state["logs"]) - _MAX_LOGS]
def _run_get_rt_batch(targets: list) -> None:
settings = load_settings()
sms = settings.get("sms") or {}
provider = sms.get("provider") or "smscode"
sms_configured = bool((sms.get(provider) or {}).get("apiKey"))
proxies = load_proxies()
ok_proxies = [p for p in proxies if p.get("status") == "ok"]
pool = ok_proxies if ok_proxies else proxies
try:
concurrency = int(settings.get("concurrency") or 1)
except (TypeError, ValueError):
concurrency = 1
concurrency = max(1, min(concurrency, len(targets) or 1))
with _rt_lock:
_rt_state.update({"running": True, "total": len(targets), "done": 0, "ok": 0,
"banned": 0, "fail": 0, "started_at": int(time.time() * 1000), "finished_at": 0})
_rt_log("info", f"====== 批量拿 RT 开始:{len(targets)} 个账号 并发 {concurrency} "
f"可用代理 {len(ok_proxies)}/{len(proxies)} 接码 {'已配置' if sms_configured else '未配置'} ======")
queue = list(enumerate(targets))
q_lock = threading.Lock()
def worker(wid: int) -> None:
while True:
with q_lock:
if not queue:
return
idx, target = queue.pop(0)
proxy = pool[idx % len(pool)] if pool else None
with _rt_lock:
_rt_state["done"] += 1
done = _rt_state["done"]
_rt_log("info", f"—— [{done}/{len(targets)}] 线程#{wid} 拿 RT ——", target.get("email"))
payload = {
"account": {
"email": target.get("email"),
"password": target.get("password"),
"clientId": target.get("clientId"),
"refreshToken": target.get("refreshToken"),
},
"settings": settings,
"proxy": proxy,
}
result = None
try:
proc = subprocess.run(
[NODE_BIN, RT_RUNNER],
input=json.dumps(payload),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(NODE_DIR),
timeout=600,
)
for line in (proc.stderr or "").splitlines():
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
_rt_log(rec.get("level") or "info", rec.get("msg") or "", rec.get("account") or target.get("email"))
except Exception:
_rt_log("info", line, target.get("email"))
out = (proc.stdout or "").strip()
result = json.loads(out) if out else {"ok": False, "banned": False, "error": "拿 RT 进程无输出"}
except FileNotFoundError:
result = {"ok": False, "banned": False, "error": f"未找到 Node 可执行文件:{NODE_BIN}"}
except subprocess.TimeoutExpired:
result = {"ok": False, "banned": False, "error": "拿 RT 超时(600s"}
except Exception as exc: # noqa: BLE001
result = {"ok": False, "banned": False, "error": str(exc)}
# 写回 plus.json
with _plus_lock:
items = load_plus()
rec = next((x for x in items if x.get("email", "").lower() == target.get("email", "").lower()), None)
if result.get("ok"):
if rec is not None:
rec["codexRt"] = result.get("refreshToken")
if result.get("clientId"):
rec["codexClientId"] = result.get("clientId")
if result.get("accessToken"):
rec["codexAccessToken"] = result.get("accessToken")
if result.get("idToken"):
rec["codexIdToken"] = result.get("idToken")
rec["codexStatus"] = "ok"
rec["banned"] = False
with _rt_lock:
_rt_state["ok"] += 1
_rt_log("ok", "已获取 refresh_token 并写回", target.get("email"))
elif result.get("banned"):
if rec is not None:
rec["banned"] = True
rec["banReason"] = result.get("reason") or ""
rec["codexStatus"] = "banned"
with _rt_lock:
_rt_state["banned"] += 1
_rt_log("warn", "账号已封禁(403 停用/删除),标注为不可用", target.get("email"))
else:
if rec is not None:
rec["codexStatus"] = "failed"
with _rt_lock:
_rt_state["fail"] += 1
_rt_log("error", "拿 RT 失败:" + (result.get("error") or "未知原因"), target.get("email"))
save_plus(items)
threads = [threading.Thread(target=worker, args=(k + 1,), name=f"rt-{k+1}", daemon=True) for k in range(concurrency)]
for t in threads:
t.start()
for t in threads:
t.join()
with _rt_lock:
_rt_state["running"] = False
_rt_state["finished_at"] = int(time.time() * 1000)
ok, banned, fail = _rt_state["ok"], _rt_state["banned"], _rt_state["fail"]
_rt_log("ok", f"====== 批量拿 RT 结束:成功 {ok} 封号 {banned} 失败 {fail} ======")
# ---------- POST /api/plus/get-rt ----------
@bp.post("/api/plus/get-rt")
def api_plus_get_rt():
body = request.get_json(silent=True) or {}
emails = [str(e).lower() for e in (body.get("emails") or [])]
items = load_plus()
targets = [a for a in items if a.get("email", "").lower() in emails]
if not targets:
return jsonify({"error": "没有匹配的账号"}), 400
with _rt_lock:
if _rt_state["running"]:
return jsonify({"error": "已有拿 RT 任务正在进行"}), 409
_rt_log("info", f"收到批量拿 RT 请求:{len(targets)} 个账号")
threading.Thread(target=_run_get_rt_batch, args=(targets,), name="rt-batch", daemon=True).start()
return jsonify({"started": len(targets)})
# ---------- GET /api/plus/rt-status(拿 RT 进度/日志,供前端轮询)----------
@bp.get("/api/plus/rt-status")
def api_plus_rt_status():
with _rt_lock:
return jsonify({
"running": _rt_state["running"],
"total": _rt_state["total"],
"done": _rt_state["done"],
"ok": _rt_state["ok"],
"banned": _rt_state["banned"],
"fail": _rt_state["fail"],
"logs": list(_rt_state["logs"][-200:]),
})
# ==========================================================================
# 协议注册(复用 get-rt.js 的无密码 OAuth 协议流程,跑在邮箱池的未使用邮箱上)
# ==========================================================================
# ---------- 日志美化:把注册/重拿RT 的技术日志压成「纯中文 ≤15 字 脱敏」短语 ----------
# 设计:集中一个「关键词→中文短语」有序映射;命中即替换;未命中且含技术噪声则丢弃,
# 未命中但本身已是短中文则原样保留。时间戳/邮箱由前端浮层展示,文本不再重复技术串。
_BEAUTIFY_RULES = [
(r"开始注册|协议注册开始|====== 协议注册开始|浏览器注册开始|====== 浏览器注册开始", "开始注册"),
(r"本次完成|协议注册结束|====== 协议注册结束|浏览器注册结束|====== 浏览器注册结束", "本次完成"),
(r"浏览器启动|启动浏览器", "启动浏览器"),
(r"获取授权成功|获取授权中|授权成功", "获取授权中"),
(r"账号登录成功|登录成功", "账号登录成功"),
(r"开始重拿|重拿RT中|重新登录中", "重拿RT中"),
(r"重拿成功", "重拿成功"),
(r"重拿失败", "重拿失败"),
# 批量绑手机拿RT:与重拿RT同一套 codex 登录+手机+换RT 流程,仅日志用词区分。
(r"开始绑机|绑手机拿RT|绑机拿RT", "绑机拿RT中"),
(r"绑机成功|绑手机成功", "绑机成功"),
(r"绑机失败|绑手机失败", "绑机失败"),
(r"步骤1|初始化 ?OAuth|初始化会话", "正在初始化"),
(r"已建号但后续失败|burned", "建号后失败"),
(r"user/?register|创建账号|create-account/password|POST .*register", "创建账号中"),
(r"authorize/?continue|提交邮箱", "提交邮箱"),
(r"email-?otp/?send|请求邮箱验证码|发送验证码", "发送验证码"),
(r"命中新鲜.*验证码|已收到验证码|已收到.*码:", "已收到验证码"),
(r"从 ?Outlook 收取|收取邮箱验证码|等待 ?verification|查收.*等待|等待短信验证码|等待验证码", "等待验证码"),
(r"email-?otp/?validate.*(200|通过)|邮箱验证通过", "邮箱验证通过"),
(r"suspicious|号码被拒|拒绝手机号", "号码被拒重试"),
(r"NO_BALANCE|余额不足|接码余额", "接码余额不足"),
(r"手机验证失败|拿号失败|短信验证码校验失败|拿号超时|短信.*超时", "手机验证失败"),
(r"add[_-]?phone|需要手机验证|要求手机验证|phone[_-]?verification", "需要手机验证"),
(r"请求号码|拿号|获取手机号|已获取号码|提交手机号", "获取手机号中"),
(r"phone-?otp/?validate.*(200|通过)|手机验证完成|手机验证通过", "手机验证通过"),
(r"about[_-]?you|补充资料|完善资料", "完善资料中"),
(r"consent|workspace/?select|oauth2/?auth|authorization code|获取授权|提取.*code", "获取授权中"),
(r"/oauth/?token|换取.*token|换取令牌|获取令牌", "获取令牌中"),
(r"refresh_token(第一阶段)|拿到令牌|第一阶段.*成功", "账号登录成功"),
(r"获取SUB|开始 ?SUB|第二阶段.*开始", "获取SUB中"),
(r"SUB获取成功|SUB 成功|独立 ?SUB", "SUB获取成功"),
(r"SUB获取失败|SUB 未取得|SUB.*失败", "SUB获取失败"),
(r"注册成功|已保存为 ?GPT|落库", "注册成功"),
(r"账号已保存|容错保存", "账号已保存"),
(r"已建号但后续失败|burned", "建号后失败"),
(r"账号已停用|deactivat|deleted", "账号已停用"),
(r"账号已存在|already (registered|exists)|Failed to create account", "账号已存在"),
(r"fetch failed|网络|timeout|超时|ECONN|ETIMEDOUT", "网络异常,重试中"),
(r"sentinel|安全校验", "安全校验失败"),
(r"region|territory|unsupported_country|无资格|不支持|该号不可用", "该号不可用"),
]
_BEAUTIFY_COMPILED = [(re.compile(p, re.I), t) for p, t in _BEAUTIFY_RULES]
# 含以下技术噪声、且未命中任何映射的日志 → 丢弃不展示
_NOISE_RE = re.compile(
r"https?://|HTTP\b|状态:|status|code=|page\.type|continue_url|follow\[|cookie|/api/|"
r"login_verifier|consent_challenge|workspace_id|session_dump|localhost|%[0-9A-Fa-f]{2}|"
r"\bGET\b|\bPOST\b|refresh_token|access_token|oai-|device|订单|referer|json|body=|"
r"仅接受收件时间|返回 page|结果状态|线程#|收到协议注册请求|收尾状态机|"
r"使用接码平台|换号重试|次:国家|第 ?\d+/\d+|channel=|订单|WAIT_CODE",
re.I,
)
_HAN_RE = re.compile(r"[\u4e00-\u9fff]")
def _beautify_log(msg: str) -> str | None:
"""把一条原始日志压成 ≤15 字纯中文短语;返回 None 表示丢弃(纯技术噪声)。"""
s = (msg or "").strip()
if not s:
return None
for rx, text in _BEAUTIFY_COMPILED:
if rx.search(s):
return text
# 未命中映射:含技术噪声 → 丢弃;否则若是短中文原样保留(截断 15 字)
if _NOISE_RE.search(s):
return None
if _HAN_RE.search(s) and len(s) <= 30:
return s[:15]
return None
def _reg_log(level: str, msg: str, email: str = "") -> None:
clean = _beautify_log(str(msg))
if clean is None:
return
entry = {"ts": int(time.time() * 1000), "level": level, "msg": clean, "account": email or ""}
with _reg_lock:
_reg_state["logs"].append(entry)
if len(_reg_state["logs"]) > _MAX_LOGS:
del _reg_state["logs"][: len(_reg_state["logs"]) - _MAX_LOGS]
def _save_registered_account(email: str, result: dict) -> None:
with _plus_lock:
items = load_probe()
key = email.lower()
rec = next((x for x in items if x.get("email", "").lower() == key), None)
if rec is None:
rec = {
"id": str(uuid.uuid4()),
"email": email,
"password": "",
"clientId": result.get("clientId") or SUB2API_DEFAULT_CLIENT_ID,
"refreshToken": "",
"accessToken": "",
"chatgptAccountId": "",
"planType": "",
"createdAt": int(time.time() * 1000),
}
items.append(rec)
# 兜底:历史注册记录可能缺 id,补上以免前端编辑/复制按 id 查不到(find 返回 undefined
if not rec.get("id"):
rec["id"] = str(uuid.uuid4())
rec["refreshToken"] = result.get("refreshToken") or rec.get("refreshToken", "")
rec["accessToken"] = result.get("accessToken") or rec.get("accessToken", "")
if result.get("password"):
rec["password"] = result.get("password")
if result.get("clientId"):
rec["clientId"] = result.get("clientId")
# 第二阶段 SUB / sub2api 授权结果(best-effort):拿到则记 subOk=True 并保存 SUB 令牌,
# 没拿到则 subOk=False(账号第一阶段信息仍然落库,SUB 列显示"否")。
rec["subOk"] = bool(result.get("subOk"))
if result.get("subOk"):
rec["subClientId"] = result.get("subClientId") or ""
rec["subRefreshToken"] = result.get("subRefreshToken") or ""
if result.get("subAccessToken"):
rec["subAccessToken"] = result.get("subAccessToken")
# 网页注册产物(无手机验证策略):持久化 网页AT / 网页RT / 会话cookie(CK) / 2FA密钥 到账号记录。
# 这些字段只要 runner 返回就存,不再局限于 phonePending 分支(用户要求核实密码/AT/RT/2FA 均落库)。
if result.get("webAccessToken"):
rec["webAccessToken"] = result.get("webAccessToken")
if result.get("webRefreshToken"):
rec["webRefreshToken"] = result.get("webRefreshToken")
if result.get("twoFactorSecret"):
rec["twoFactorSecret"] = result.get("twoFactorSecret")
if result.get("chatgptAccountId"):
rec["chatgptAccountId"] = result.get("chatgptAccountId")
cookies = result.get("cookies")
if isinstance(cookies, list) and cookies:
rec["cookies"] = cookies
# phonePending:网页号尚无 codex RT,标记「手机/RT 待补」,SUB 列显示否(现策略不再自动绑手机)。
if result.get("phonePending"):
rec["phonePending"] = True
rec["registeredAt"] = int(time.time() * 1000)
save_probe(items)
def _run_reg_batch(targets: list, threads: int, mode: str = "protocol", headless: bool = True,
skip_phone: bool = True, web_client: bool = True) -> None:
settings = load_settings()
proxies = load_proxies()
pool = [p for p in proxies if p.get("status") == "ok"] or proxies
threads = max(1, min(int(threads or 1), len(targets) or 1, 20))
# 注册方式:protocol=HTTP 直连(reg_runner.mjs);browser=CloakBrowser 浏览器(cloak_reg_runner.mjs)。
is_browser = str(mode or "protocol").lower() == "browser"
runner = CLOAK_REG_RUNNER if is_browser else REG_RUNNER
license_key = str(settings.get("cloakLicenseKey") or "").strip()
with _reg_lock:
_reg_state.update({"running": True, "stop": False, "total": len(targets), "done": 0,
"ok": 0, "fail": 0, "started_at": int(time.time() * 1000), "finished_at": 0})
if is_browser:
_reg_log("info", f"====== 浏览器注册开始:{len(targets)} 个邮箱 并发 {threads} "
f"{'无头' if headless else '有头'} 可用代理 {len(pool)} "
f"KEY {'已配置' if license_key else '未配置'} ======")
if not license_key:
_reg_log("warn", "未配置 CloakBrowser KEY,将以免费额度启动(可能受限)")
else:
_reg_log("info", f"====== 协议注册开始:{len(targets)} 个邮箱 并发 {threads} 可用代理 {len(pool)} ======")
queue = list(enumerate(targets))
q_lock = threading.Lock()
def worker(wid: int) -> None:
while True:
with _reg_lock:
if _reg_state["stop"]:
return
with q_lock:
if not queue:
return
idx, target = queue.pop(0)
# 随机取代理:这些代理是同一网关的不同 session(sid),随机可换出口 IP,
# 避免单号测试时永远命中同一个坏 session 导致 fetch failed。
proxy = random.choice(pool) if pool else None
email = target.get("email")
with _reg_lock:
_reg_state["done"] += 1
done = _reg_state["done"]
_reg_log("info", f"—— [{done}/{len(targets)}] 线程#{wid} 开始{'浏览器' if is_browser else '协议'}注册 ——", email)
payload = {"email": email, "settings": settings, "proxy": proxy, "otpBase": OTP_INTERNAL_BASE}
# 浏览器模式额外透传:无头开关 + CloakBrowser license key(协议模式忽略这些字段)。
if is_browser:
payload["headless"] = bool(headless)
payload["licenseKey"] = license_key
# 新策略:注册阶段不做手机验证——走到手机页就采集 CK+AT 入库(手机/RT 由后续批量绑手机再补)。
payload["skipPhone"] = bool(skip_phone)
# 走 chatgpt.com 网页客户端注册(app_X8zY6…):实测无密码(邮箱验证码即登录)、不强制手机,
# 注册完成即拿「会话 cookie(CK)+网页 AT」。codex 授权流(app_EMoam…)会强制手机,只留给
# 后续「批量绑手机拿RT」。默认开启;如需回退 codex 注册流,前端传 webClient=false。
payload["webClient"] = bool(web_client)
result = None
try:
# 用 Popen 实时把 Node 的 stderr 日志逐行喂给 reg 日志(原来 subprocess.run 只能在子进程
# 结束后一次性读取,导致跑手机验证等长耗时步骤时前端完全看不到进度)。
proc = subprocess.Popen(
[NODE_BIN, runner],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", cwd=str(NODE_DIR),
)
try:
proc.stdin.write(json.dumps(payload))
proc.stdin.close()
except Exception:
pass
def _pump_stderr() -> None:
try:
for line in proc.stderr:
line = (line or "").strip()
if not line:
continue
try:
rec = json.loads(line)
_reg_log(rec.get("level") or "info", rec.get("msg") or "", rec.get("account") or email)
except Exception:
_reg_log("info", line, email)
except Exception:
pass
et = threading.Thread(target=_pump_stderr, name=f"reg-err-{wid}", daemon=True)
et.start()
timed_out = {"v": False}
def _kill_on_timeout() -> None:
timed_out["v"] = True
try:
proc.kill()
except Exception:
pass
# 浏览器模式首启会下载隐身 Chromium(~200MB) 且有真实渲染/手机验证,耗时更长,
# 放宽到 900s;协议模式维持 600s。
reg_timeout = 900 if is_browser else 600
timer = threading.Timer(reg_timeout, _kill_on_timeout)
timer.start()
try:
out = (proc.stdout.read() or "").strip()
proc.wait()
finally:
timer.cancel()
et.join(timeout=5)
if timed_out["v"]:
result = {"ok": False, "error": f"注册超时({reg_timeout}s"}
else:
result = json.loads(out) if out else {"ok": False, "error": "注册进程无输出"}
except FileNotFoundError:
result = {"ok": False, "error": f"未找到 Node 可执行文件:{NODE_BIN}"}
except Exception as exc: # noqa: BLE001
result = {"ok": False, "error": str(exc)}
if result.get("ok"):
_save_registered_account(email, result)
with _plus_lock:
mb = load_mailbox()
me = next((x for x in mb.get("entries", []) if x.get("email", "").lower() == email.lower()), None)
if me:
me["used"] = True
me["usedAt"] = int(time.time() * 1000)
save_mailbox(mb)
with _reg_lock:
_reg_state["ok"] += 1
_reg_log("ok", "注册成功,已保存为 GPT 账号并标记邮箱已使用", email)
else:
# user/register 已成功但后续失败:账号已在 OpenAI 侧创建,把邮箱标记 burned,
# 避免下次再用同一邮箱注册命中"账号已存在"burned 也计入 used,不再进未使用池)。
if result.get("registered"):
with _plus_lock:
mb = load_mailbox()
me = next((x for x in mb.get("entries", []) if x.get("email", "").lower() == email.lower()), None)
if me:
me["used"] = True
me["burned"] = True
me["usedAt"] = int(time.time() * 1000)
me["burnReason"] = (result.get("error") or "注册中途失败")[:200]
save_mailbox(mb)
_reg_log("warn", "该邮箱已建号但后续失败,已标记 burned(不再复用)", email)
with _reg_lock:
_reg_state["fail"] += 1
_reg_log("error", "注册失败:" + (result.get("error") or "未知原因"), email)
ts = [threading.Thread(target=worker, args=(k + 1,), name=f"reg-{k+1}", daemon=True) for k in range(threads)]
for t in ts:
t.start()
for t in ts:
t.join()
with _reg_lock:
_reg_state["running"] = False
_reg_state["finished_at"] = int(time.time() * 1000)
ok, fail = _reg_state["ok"], _reg_state["fail"]
_reg_log("ok", f"====== {'浏览器' if is_browser else '协议'}注册结束:成功 {ok} 失败 {fail} ======")
@bp.post("/api/reg/start")
def api_reg_start():
body = request.get_json(silent=True) or {}
try:
count = max(1, int(body.get("count") or 1))
except (TypeError, ValueError):
count = 1
try:
threads = max(1, int(body.get("threads") or 1))
except (TypeError, ValueError):
threads = 1
# 注册方式:protocol(默认,HTTP 直连)/ browserCloakBrowser);headless 仅浏览器模式有意义。
mode = str(body.get("mode") or "protocol").lower()
if mode not in ("protocol", "browser"):
mode = "protocol"
headless = body.get("headless")
headless = True if headless is None else bool(headless)
# 新策略默认注册阶段跳过手机(浏览器模式);前端可显式传 skipPhone=false 恢复「注册即绑手机」旧行为。
skip_phone = body.get("skipPhone")
skip_phone = True if skip_phone is None else bool(skip_phone)
# 浏览器注册默认走 chatgpt.com 网页客户端(不强制手机、拿 CK+AT);传 webClient=false 回退 codex 注册流。
web_client = body.get("webClient")
web_client = True if web_client is None else bool(web_client)
with _reg_lock:
if _reg_state["running"]:
return jsonify({"error": "已有注册任务正在进行"}), 409
mb = load_mailbox()
unused = [e for e in mb.get("entries", []) if not e.get("used") and e.get("credential")]
if not unused:
return jsonify({"error": "邮箱池没有可用的未使用邮箱"}), 400
targets = unused[:count]
label = "浏览器" if mode == "browser" else "协议"
_reg_log("info", f"收到{label}注册请求:计划 {len(targets)} 个(未使用剩余 {len(unused)}),并发 {threads}"
+ (f"{'无头' if headless else '有头'}" if mode == "browser" else ""))
threading.Thread(target=_run_reg_batch, args=(targets, threads, mode, headless, skip_phone, web_client),
name="reg-batch", daemon=True).start()
return jsonify({"started": len(targets), "mode": mode})
# ---------- 重拿RT:对已存在账号跑一次全新 codex 登录(register:false, 全新 device_id ----------
# 复用协议注册的日志浮层(_reg_state / _reg_log)显示进度;成功则用新拿到的 codex RT 更新
# 账号 refreshToken / accessToken / clientId,并标记 SUB=是(这份 RT 本身就是 codex/sub2api 凭据)。
def _run_refetch_batch(accounts: list, threads: int, label: str = "重拿RT") -> None:
# label 用于区分「重拿RT」与「批量绑手机拿RT」——两者底层完全一致(codex 客户端全新登录→
# 需要手机则接码→换独立 RT),只是入口/日志用词不同。bind 时成功后额外清 phonePending。
is_bind = label.startswith("绑机")
prog_msg = f"{label}中"
ok_msg = "绑机成功" if is_bind else "重拿成功"
fail_prefix = "绑机失败:" if is_bind else "重拿失败:"
settings = load_settings()
proxies = load_proxies()
pool = [p for p in proxies if p.get("status") == "ok"] or proxies
threads = max(1, min(int(threads or 1), len(accounts) or 1, 20))
with _reg_lock:
_reg_state.update({"running": True, "stop": False, "total": len(accounts), "done": 0,
"ok": 0, "fail": 0, "started_at": int(time.time() * 1000), "finished_at": 0})
_reg_log("info", f"====== 开始{label}{len(accounts)} 个账号 并发 {threads} ======")
queue = list(accounts)
q_lock = threading.Lock()
def worker(wid: int) -> None:
while True:
with _reg_lock:
if _reg_state["stop"]:
return
with q_lock:
if not queue:
return
acc = queue.pop(0)
proxy = random.choice(pool) if pool else None
email = acc.get("email") or ""
with _reg_lock:
_reg_state["done"] += 1
_reg_log("info", prog_msg, email)
# 运行方式按账号数据分流:
# 有 CK(cookies) 的「网页无密码号」→ 浏览器 bindPhone(CK 恢复/邮箱码重登 → codex 授权 → 手机 → RT);
# 无 CK 的「有密码号」(协议模式产出)→ 原 HTTP 密码重登(get-rt.js)。
ck = acc.get("cookies") if isinstance(acc.get("cookies"), list) else []
use_browser = bool(ck)
if use_browser:
runner_path = CLOAK_REG_RUNNER
payload = {
"email": email,
"password": acc.get("password") or "",
"cookies": ck,
"bindPhone": True,
"settings": settings,
"proxy": proxy,
"otpBase": OTP_INTERNAL_BASE,
# 浏览器 bindPhone 默认有头(与已验证的注册路径一致、便于观测、抗检测更稳)。
"headless": False,
"licenseKey": str(settings.get("cloakLicenseKey") or "").strip(),
}
else:
runner_path = REFETCH_RUNNER
payload = {
"email": email,
"password": acc.get("password") or "",
"settings": settings,
"proxy": proxy,
"otpBase": OTP_INTERNAL_BASE,
}
result = None
try:
proc = subprocess.Popen(
[NODE_BIN, runner_path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", cwd=str(NODE_DIR),
)
try:
proc.stdin.write(json.dumps(payload))
proc.stdin.close()
except Exception:
pass
def _pump_stderr() -> None:
try:
for line in proc.stderr:
line = (line or "").strip()
if not line:
continue
# 诊断行([CAP-DIAG]/[BIND-DIAG]/[CLOAK-DIAG])不进用户日志,保持纯中文≤15字。
if line.startswith("["):
continue
try:
rec = json.loads(line)
_reg_log(rec.get("level") or "info", rec.get("msg") or "", rec.get("account") or email)
except Exception:
pass
except Exception:
pass
et = threading.Thread(target=_pump_stderr, name=f"refetch-err-{wid}", daemon=True)
et.start()
timed_out = {"v": False}
def _kill_on_timeout() -> None:
timed_out["v"] = True
try:
proc.kill()
except Exception:
pass
# 浏览器 bindPhone(CK 恢复/邮箱码重登 + 手机多国轮试)更耗时,放宽到 1200s;HTTP 重登 600s。
to_sec = 1200 if use_browser else 600
timer = threading.Timer(to_sec, _kill_on_timeout)
timer.start()
try:
out = (proc.stdout.read() or "").strip()
proc.wait()
finally:
timer.cancel()
et.join(timeout=5)
if timed_out["v"]:
result = {"ok": False, "error": f"绑机/重拿RT超时({to_sec}s"}
else:
result = json.loads(out) if out else {"ok": False, "error": "重拿RT进程无输出"}
except FileNotFoundError:
result = {"ok": False, "error": f"未找到 Node 可执行文件:{NODE_BIN}"}
except Exception as exc: # noqa: BLE001
result = {"ok": False, "error": str(exc)}
if result.get("ok") and result.get("refreshToken"):
with _plus_lock:
items = load_probe()
rec = next((x for x in items if str(x.get("id")) == str(acc.get("id"))), None)
if rec is None:
rec = next((x for x in items if x.get("email", "").lower() == email.lower()), None)
if rec is not None:
rec["refreshToken"] = result.get("refreshToken") or rec.get("refreshToken")
if result.get("accessToken"):
rec["accessToken"] = result.get("accessToken")
rec["clientId"] = result.get("clientId") or rec.get("clientId") or SUB2API_DEFAULT_CLIENT_ID
# 新拿到的 codex RT 本身即 sub2api(SUB) 凭据
rec["subOk"] = True
rec["subClientId"] = result.get("clientId") or rec.get("clientId")
rec["subRefreshToken"] = result.get("refreshToken")
if result.get("accessToken"):
rec["subAccessToken"] = result.get("accessToken")
rec["status"] = "valid"
# 绑手机拿到 RT 后,该账号不再是「待绑手机」态:清 phonePending。
rec["phonePending"] = False
rec["lastCheckedAt"] = int(time.time() * 1000)
save_probe(items)
with _reg_lock:
_reg_state["ok"] += 1
_reg_log("ok", ok_msg, email)
else:
with _reg_lock:
_reg_state["fail"] += 1
_reg_log("error", fail_prefix + (result.get("error") or "未知原因"), email)
ts = [threading.Thread(target=worker, args=(k + 1,), name=f"refetch-{k+1}", daemon=True) for k in range(threads)]
for t in ts:
t.start()
for t in ts:
t.join()
with _reg_lock:
_reg_state["running"] = False
_reg_state["finished_at"] = int(time.time() * 1000)
ok, fail = _reg_state["ok"], _reg_state["fail"]
_reg_log("ok", f"====== {label}结束:成功 {ok} 失败 {fail} ======")
@bp.post("/api/probe/refetch-rt")
def api_probe_refetch_rt():
body = request.get_json(silent=True) or {}
ids = body.get("ids") or []
emails = body.get("emails") or []
try:
threads = max(1, min(int(body.get("threads") or 3), 5))
except (TypeError, ValueError):
threads = 3
with _reg_lock:
if _reg_state["running"]:
return jsonify({"error": "已有注册/重拿任务正在进行,请稍后"}), 409
items = load_probe()
idset = {str(x) for x in ids}
emset = {str(x).lower() for x in emails}
picked = [a for a in items
if (idset and str(a.get("id")) in idset) or (emset and str(a.get("email", "")).lower() in emset)]
if not picked:
return jsonify({"error": "未选择有效账号"}), 400
threading.Thread(target=_run_refetch_batch, args=(picked, threads), name="refetch-batch", daemon=True).start()
return jsonify({"started": len(picked)})
@bp.post("/api/probe/bind-phone-rt")
def api_probe_bind_phone_rt():
"""批量绑手机拿RT:对「跳过手机注册」产出的 phonePending(有CK、无RT)账号,
用邮箱+固定密码重新登录(路径A)→ 需要手机则走现有 smsbower 多国轮试 → codex 授权换独立 RT。
与 /api/probe/refetch-rt 共用 _run_refetch_batch,仅目标筛选与日志用词不同:
- 传了 ids/emails:绑手机指定账号(尊重人工选择);
- 未传:自动挑库里所有「phonePending 且无 RT」的账号。
成功后 _run_refetch_batch 会写 RT/clientId、SUB=是、并清 phonePending。"""
body = request.get_json(silent=True) or {}
ids = body.get("ids") or []
emails = body.get("emails") or []
try:
threads = max(1, min(int(body.get("threads") or 3), 5))
except (TypeError, ValueError):
threads = 3
with _reg_lock:
if _reg_state["running"]:
return jsonify({"error": "已有注册/重拿/绑机任务正在进行,请稍后"}), 409
items = load_probe()
idset = {str(x) for x in ids}
emset = {str(x).lower() for x in emails}
if idset or emset:
picked = [a for a in items
if str(a.get("id")) in idset or str(a.get("email", "")).lower() in emset]
else:
picked = [a for a in items
if a.get("phonePending") and not str(a.get("refreshToken") or "").strip()]
# 绑手机拿RT 必须有邮箱+密码才能重登;过滤掉缺密码的(避免白跑)。
picked = [a for a in picked if a.get("email") and str(a.get("password") or "").strip()]
if not picked:
return jsonify({"error": "没有可绑手机的账号(需 待绑手机/无RT 且有邮箱密码)"}), 400
threading.Thread(target=_run_refetch_batch, args=(picked, threads, "绑机拿RT"),
name="bindphone-batch", daemon=True).start()
return jsonify({"started": len(picked)})
@bp.get("/api/reg/status")
def api_reg_status():
mb = load_mailbox()
unused = sum(1 for e in mb.get("entries", []) if not e.get("used") and e.get("credential"))
with _reg_lock:
return jsonify({
"running": _reg_state["running"],
"total": _reg_state["total"],
"done": _reg_state["done"],
"ok": _reg_state["ok"],
"fail": _reg_state["fail"],
"logs": list(_reg_state["logs"][-200:]),
"unused": unused,
"finishedAt": _reg_state["finished_at"],
})
@bp.post("/api/reg/stop")
def api_reg_stop():
with _reg_lock:
if not _reg_state["running"]:
return jsonify({"ok": True})
_reg_state["stop"] = True
_reg_log("warn", "收到停止请求,将在当前进行中的账号完成后结束(不再领取新邮箱)")
return jsonify({"ok": True})
@bp.post("/api/reg/logs/clear")
def api_reg_logs_clear():
"""清空注册日志缓冲(前端点“清空”时调用,后端也一并清掉,避免下次恢复浮层又灌回旧日志)。"""
with _reg_lock:
_reg_state["logs"].clear()
return jsonify({"ok": True})
@bp.get("/api/reg/inbox")
def api_reg_inbox():
"""供 reg_runner 回读邮箱验证码:按 email 在邮箱池查凭证并读信。"""
email = (request.args.get("email") or "").strip()
if not email:
return jsonify({"messages": []})
mb = load_mailbox()
e = next((x for x in mb.get("entries", []) if x.get("email", "").lower() == email.lower()), None)
if not e or not e.get("credential"):
return jsonify({"messages": [], "error": "邮箱不在池中或缺少凭证"})
try:
msgs = _mailbox_messages(email, e.get("credential", ""), 15)
return jsonify({"messages": msgs})
except Exception as exc: # noqa: BLE001
return jsonify({"messages": [], "error": f"{type(exc).__name__}: {exc}"})
# ---------- POST /api/plus/export(批量导出 SUB2API 格式)----------
# Export = 返回与 Node 批量导出一致的 SUB2API JSON。默认导出选中且已有 codexRt 的账号。
@bp.post("/api/plus/export")
def api_plus_export():
body = request.get_json(silent=True) or {}
emails = body.get("emails")
items = load_plus()
if emails:
target_set = {str(e).lower() for e in emails}
base = [a for a in items if a.get("email", "").lower() in target_set]
else:
base = items
with_rt = [a for a in base if isinstance(a.get("codexRt"), str) and a.get("codexRt")]
accounts = []
for a in with_rt:
credentials = {
"refresh_token": a.get("codexRt"),
"client_id": a.get("codexClientId") or SUB2API_DEFAULT_CLIENT_ID,
"email": a.get("email"),
}
if a.get("codexAccessToken"):
credentials["access_token"] = a.get("codexAccessToken")
if a.get("codexIdToken"):
credentials["id_token"] = a.get("codexIdToken")
accounts.append({
"name": a.get("email"),
"platform": "openai",
"type": "oauth",
"credentials": credentials,
"extra": {"email": a.get("email")},
"concurrency": 1,
"priority": 1,
"rate_multiplier": 1,
"auto_pause_on_expired": True,
})
payload = {
"type": "sub2api-data",
"version": 1,
"exported_at": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
"proxies": [],
"accounts": accounts,
}
ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime())
filename = f"sub2api_export_{ts}.json"
return Response(
json.dumps(payload, ensure_ascii=False, indent=2),
mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"',
"X-Export-Count": str(len(accounts))},
)
# ==========================================================================
# GPT 账号管理(probe / sub2api)—— 从 auto probe.js + server.js 的 /api/probe/accounts
# 组忠实移植。数据存储:card-binding/data/probe_accounts.json。
# TOKEN 刷新是对 https://auth.openai.com/oauth/token 的普通表单 POST(无 sentinel/PoW),
# 因此用 httpx 在 Python 里原样复刻,并可经代理池转发(应对区域限制)。
# ==========================================================================
PROBE_FILE = DATA_DIR / "probe_accounts.json"
OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token"
OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
OPENAI_SCOPE = "openid profile email offline_access"
CHROME_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
PROBE_TOKEN_NEAR_EXP_SEC = 24 * 3600 # 临近过期阈值:1 天
_probe_lock = threading.RLock()
_probe_rr = {"i": 0} # 代理池 round-robin 游标
# 自动刷新运行态(mirror server.js 的 probeTokenAuto
_probe_auto = {"running": False, "lastRunAt": 0, "lastResult": None}
_probe_auto_lock = threading.RLock()
_probe_auto_wake = threading.Event()
# 批量检测运行态(≤5 线程并发跑实时套餐校验)
_probe_detect = {"running": False, "total": 0, "done": 0, "ok": 0, "fail": 0, "startedAt": 0, "finishedAt": 0}
_probe_detect_lock = threading.RLock()
def load_probe() -> list:
with _probe_lock:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not PROBE_FILE.exists():
return []
try:
data = json.loads(PROBE_FILE.read_text(encoding="utf-8"))
if not isinstance(data, list):
return []
except Exception:
return []
# 兜底补 id:历史/注册落库的记录可能缺 id,导致前端按 id 查不到而无法编辑/复制。
changed = False
for a in data:
if isinstance(a, dict) and not a.get("id"):
a["id"] = str(uuid.uuid4())
changed = True
if changed:
save_probe(data)
return data
def save_probe(items: list) -> None:
with _probe_lock:
DATA_DIR.mkdir(parents=True, exist_ok=True)
PROBE_FILE.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
def save_settings(data: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------- 邮箱池(email----专用密码 / email----取件API链接)----------
def load_mailbox() -> dict:
try:
data = json.loads(MAILBOX_FILE.read_text(encoding="utf-8"))
except Exception:
data = {}
if not isinstance(data, dict):
return {"split": False, "entries": []}
entries = data.get("entries")
return {
"split": bool(data.get("split")),
"entries": entries if isinstance(entries, list) else [],
}
def save_mailbox(data: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
MAILBOX_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def parse_mailbox_lines(text: str) -> list:
"""每行 email----credentialcredential 为 http(s) 链接则视为取件 API,否则为 IMAP 专用密码。"""
out: list = []
seen: set = set()
for raw in str(text or "").split("\n"):
line = raw.replace("\r", "").strip()
if not line:
continue
if "----" in line:
parts = line.split("----", 1)
elif "\t" in line:
parts = line.split("\t", 1)
elif "|" in line:
parts = line.split("|", 1)
else:
parts = re.split(r"\s+", line, maxsplit=1)
if len(parts) < 2:
continue
email = parts[0].strip()
cred = parts[1].strip()
if not _EMAIL_RE.match(email) or not cred:
continue
key = email.lower()
if key in seen:
continue
seen.add(key)
typ = "api" if re.match(r"^https?://", cred, re.I) else "imap"
out.append({"email": email, "credential": cred, "type": typ, "used": False})
return out
def merge_mailbox_entries(existing: list, parsed: list) -> tuple:
"""把 parsed 合并进 existing:已存在的邮箱更新凭证但保留 used 状态;新邮箱追加(used=False)。"""
by_email = {e.get("email", "").lower(): e for e in existing}
added = 0
for item in parsed:
key = item["email"].lower()
cur = by_email.get(key)
if cur:
cur["credential"] = item["credential"]
cur["type"] = item["type"]
else:
existing.append(item)
by_email[key] = item
added += 1
return existing, added
def _mailbox_public(e: dict) -> dict:
return {
"email": e.get("email", ""),
"type": e.get("type", "imap"),
"used": bool(e.get("used")),
"credential": e.get("credential", ""),
}
def _mailbox_to_line(e: dict) -> str:
return f"{e.get('email', '')}----{e.get('credential', '')}"
_IMAP_HOSTS = {
"icloud.com": "imap.mail.me.com",
"me.com": "imap.mail.me.com",
"mac.com": "imap.mail.me.com",
"gmail.com": "imap.gmail.com",
"googlemail.com": "imap.gmail.com",
"outlook.com": "outlook.office365.com",
"hotmail.com": "outlook.office365.com",
"live.com": "outlook.office365.com",
}
def _imap_host_for(email: str) -> str:
dom = email.rsplit("@", 1)[-1].lower()
return _IMAP_HOSTS.get(dom, "imap." + dom)
def _extract_code(text: str) -> str:
s = str(text or "")
# OpenAI 验证码固定 6 位,优先匹配独立的 6 位数字(避免误取年份/金额等)
m = re.search(r"(?<!\d)(\d{6})(?!\d)", s)
if m:
return m.group(1)
# 退回 4-8 位,但跳过明显的 4 位年份
for mm in re.finditer(r"(?<!\d)(\d{4,8})(?!\d)", s):
val = mm.group(1)
if len(val) == 4 and 1990 <= int(val) <= 2100:
continue
return val
return ""
def _strip_html_text(html: str) -> str:
"""去掉 style/script/标签,得到可见正文,用于提取验证码。"""
s = re.sub(r"<style[^>]*>.*?</style>", " ", str(html or ""), flags=re.S | re.I)
s = re.sub(r"<script[^>]*>.*?</script>", " ", s, flags=re.S | re.I)
s = re.sub(r"<[^>]+>", " ", s)
s = re.sub(r"&nbsp;|&amp;|&zwnj;|&#\d+;", " ", s)
return re.sub(r"\s+", " ", s).strip()
def _clean_header_text(s: str) -> str:
"""清理发件人/主题/日期等短字段:解码常见实体并折叠空白,
但保留形如 <addr@domain> 的邮箱地址(不当作 HTML 标签删除)。"""
t = str(s or "")
t = t.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
t = re.sub(r"&nbsp;|&zwnj;|&#\d+;", " ", t)
return re.sub(r"\s+", " ", t).strip()
def _parse_api_mailbox_html(html: str, limit: int = 15) -> list:
"""解析第三方取件 API 返回的 HTMLicloud-api.top 等)为逐封邮件记录。
页面结构:每封邮件为一个 <div class="card">,内含 .fr(发件人)/.su(主题)/.dt(日期)/.bd(正文)。
返回 get-rt.js 期望的形状:[{from_addr, subject, body, received_at(ISO), extracted_code}]。"""
from email.utils import parsedate_to_datetime
text = str(html or "")
chunks = text.split('<div class="card">')[1:]
out: list = []
for ch in chunks[:limit]:
frm_m = re.search(r'<div class="fr">(.*?)</div>', ch, re.S)
su_m = re.search(r'<div class="su">(.*?)</div>', ch, re.S)
dt_m = re.search(r'<div class="dt">(.*?)</div>', ch, re.S)
bd_m = re.search(r'<div class="bd">(.*)$', ch, re.S)
frm = _clean_header_text(frm_m.group(1)) if frm_m else ""
subject = _clean_header_text(su_m.group(1)) if su_m else ""
raw_date = _clean_header_text(dt_m.group(1)) if dt_m else ""
body = _strip_html_text(bd_m.group(1)) if bd_m else ""
try:
dt = parsedate_to_datetime(raw_date)
recv_iso = dt.isoformat() if dt else raw_date
except Exception:
recv_iso = raw_date
code = _extract_code(subject) or _extract_code(body)
out.append({
"from_addr": frm,
"subject": subject,
"body": body,
"received_at": recv_iso,
"extracted_code": code,
})
# 无 card 结构时退回整页扫描(尽量仍可取到码)
if not out:
clean = _strip_html_text(text)
code = _extract_code(clean)
if clean:
out.append({
"from_addr": "",
"subject": clean[:120],
"body": clean,
"received_at": "",
"extracted_code": code,
})
return out
def _msg_text(msg) -> str:
parts: list = []
try:
walk = msg.walk() if msg.is_multipart() else [msg]
for p in walk:
if p.get_content_type() not in ("text/plain", "text/html"):
continue
try:
payload = p.get_payload(decode=True)
if payload:
parts.append(payload.decode(p.get_content_charset() or "utf-8", "replace"))
except Exception:
continue
except Exception:
pass
return "\n".join(parts)
def _mailbox_test_imap(email: str, credential: str, limit: int = 5) -> dict:
import imaplib
import email as emaillib
from email.header import decode_header, make_header
host = _imap_host_for(email)
M = imaplib.IMAP4_SSL(host, 993, timeout=20)
try:
M.login(email, credential.replace(" ", ""))
M.select("INBOX", readonly=True)
typ, data = M.search(None, "ALL")
ids = data[0].split() if data and data[0] else []
ids = ids[-limit:][::-1]
messages: list = []
for i in ids:
# iCloud 对 (RFC822) 返回空,需用 BODY.PEEK[](且不置 \Seen 标记)
typ, md = M.fetch(i, "(BODY.PEEK[])")
if typ != "OK" or not md:
continue
raw = next((p[1] for p in md if isinstance(p, tuple) and len(p) >= 2 and p[1]), None)
if not raw:
continue
msg = emaillib.message_from_bytes(raw)
try:
subject = str(make_header(decode_header(msg.get("Subject", ""))))
except Exception:
subject = msg.get("Subject", "")
try:
frm = str(make_header(decode_header(msg.get("From", ""))))
except Exception:
frm = msg.get("From", "")
body = _msg_text(msg)
code = _extract_code(subject) or _extract_code(re.sub(r"<[^>]+>", " ", body))
messages.append({
"from": frm,
"subject": subject,
"date": msg.get("Date", ""),
"code": code,
})
return {"ok": True, "type": "imap", "host": host, "count": len(messages), "messages": messages}
finally:
try:
M.logout()
except Exception:
pass
def _mailbox_test_api(email: str, url: str) -> dict:
if httpx is None:
raise RuntimeError("httpx 不可用,无法请求取件 API")
r = httpx.get(url, timeout=25, follow_redirects=True)
parsed = _parse_api_mailbox_html(r.text or "", 15)
# 展示层:优先展示 OpenAI/ChatGPT 验证码那封
def _score(m):
blob = f"{m.get('from_addr','')} {m.get('subject','')}".lower()
s = 0
if "openai" in blob or "chatgpt" in blob:
s += 2
if "verification" in blob or "验证" in blob:
s += 1
return s
best = sorted(parsed, key=_score, reverse=True)[0] if parsed else None
return {
"ok": r.status_code == 200,
"type": "api",
"status": r.status_code,
"code": (best or {}).get("extracted_code", ""),
"count": len(parsed),
"messages": [{
"from": m.get("from_addr", ""),
"subject": m.get("subject", "")[:200],
"date": m.get("received_at", ""),
"code": m.get("extracted_code", ""),
} for m in parsed],
}
def _decode_header(v: str) -> str:
from email.header import decode_header, make_header
try:
return str(make_header(decode_header(v or "")))
except Exception:
return str(v or "")
def _mailbox_messages(email: str, credential: str, limit: int = 15) -> list:
"""读取邮箱最新邮件,返回 get-rt.js 期望的形状:
[{from_addr, subject, body, received_at(ISO), extracted_code}]。"""
if re.match(r"^https?://", credential, re.I):
if httpx is None:
return []
try:
r = httpx.get(credential, timeout=25, follow_redirects=True)
except Exception:
return []
return _parse_api_mailbox_html(r.text or "", limit)
import imaplib
import email as emaillib
from email.utils import parsedate_to_datetime
host = _imap_host_for(email)
M = imaplib.IMAP4_SSL(host, 993, timeout=20)
try:
M.login(email, credential.replace(" ", ""))
M.select("INBOX", readonly=True)
typ, data = M.search(None, "ALL")
ids = (data[0].split() if data and data[0] else [])[-limit:][::-1]
msgs: list = []
for i in ids:
typ, md = M.fetch(i, "(BODY.PEEK[])")
raw = None
if typ == "OK" and md:
raw = next((p[1] for p in md if isinstance(p, tuple) and len(p) >= 2 and p[1]), None)
if not raw:
continue
m = emaillib.message_from_bytes(raw)
subject = _decode_header(m.get("Subject", ""))
frm = _decode_header(m.get("From", ""))
body = _msg_text(m)
recv = m.get("Date", "")
try:
dt = parsedate_to_datetime(recv)
recv_iso = dt.isoformat() if dt else recv
except Exception:
recv_iso = recv
code = _extract_code(subject) or _extract_code(re.sub(r"<[^>]+>", " ", body))
msgs.append({
"from_addr": frm,
"subject": subject,
"body": body,
"received_at": recv_iso,
"extracted_code": code,
})
return msgs
finally:
try:
M.logout()
except Exception:
pass
def _clamp_int(v, lo, hi, default):
try:
n = int(round(float(v)))
except (TypeError, ValueError):
return default
return max(lo, min(hi, n))
# ---------- sub2api 解析:兼容 JSON 与 email----password----clientId----refreshToken 行格式 ----------
def parse_sub2api(raw) -> list:
text = raw if isinstance(raw, str) else json.dumps(raw)
text = str(text or "").strip()
if not text:
return []
# 1) 优先尝试 sub2api JSON(导出文件 / 账号数组)
obj = None
try:
obj = json.loads(text)
except Exception:
obj = None
if obj is not None:
if isinstance(obj, list):
arr = obj
elif isinstance(obj, dict) and isinstance(obj.get("accounts"), list):
arr = obj["accounts"]
else:
arr = []
out = []
for a in arr:
if not isinstance(a, dict):
continue
cred = a.get("credentials") or a.get("creds") or {}
extra = a.get("extra") or {}
email = a.get("name") or a.get("email") or extra.get("email") or ""
access_token = cred.get("access_token") or a.get("access_token") or ""
refresh_token = cred.get("refresh_token") or a.get("refresh_token") or ""
if not email and not access_token and not refresh_token:
continue
out.append({
"email": str(email or "").strip(),
"password": str(cred.get("password") or a.get("password") or ""),
"accessToken": str(access_token or ""),
"refreshToken": str(refresh_token or ""),
"clientId": str(cred.get("client_id") or a.get("client_id") or ""),
"chatgptAccountId": str(cred.get("chatgpt_account_id") or a.get("chatgpt_account_id") or ""),
"planType": str(a.get("plan_type") or cred.get("plan_type") or ""),
})
return out
# 2) 行格式(非 JSON)——逐行自动识别:
# 含分隔符(---- / tab / |)且 >=4 段 → email----password----clientId----refreshToken
# 否则整行视为一个裸 access token(仅 accessToken,其余留空)。
out = []
seen = set()
def _dedupe_key(email, at, rt):
if email:
return "email:" + email.lower()
if at:
return "at:" + at
if rt:
return "rt:" + rt
return ""
def _add(email, password, client_id, refresh_token, access_token):
key = _dedupe_key(email, access_token, refresh_token)
if not key or key in seen:
return
seen.add(key)
out.append({
"email": email,
"password": password,
"clientId": client_id,
"refreshToken": refresh_token,
"accessToken": access_token,
"chatgptAccountId": "",
"planType": "",
})
for raw_line in text.split("\n"):
line = raw_line.replace("\r", "").strip()
if not line:
continue
delim = "----" if "----" in line else ("\t" if "\t" in line else ("|" if "|" in line else None))
if delim is not None:
parts = [s.strip() for s in line.split(delim) if s.strip()]
if len(parts) >= 4 and _EMAIL_RE.match(parts[0]):
_add(parts[0], parts[1], parts[2], parts[3], "")
# 分隔符行但字段不足/首列非邮箱 → 视为非法,跳过
continue
# 无分隔符:可能是空白分隔的 4 字段,或裸 access token
ws = [s for s in re.split(r"\s+", line) if s]
if len(ws) >= 4 and _EMAIL_RE.match(ws[0]):
_add(ws[0], ws[1], ws[2], ws[3], "")
else:
# 裸 AT:JWT 不含空白,取整行作为 accessToken
_add("", "", "", "", ws[0] if len(ws) == 1 else line)
return out
# ---------- JWT / 账号派生 ----------
def decode_jwt_payload(token: str):
try:
parts = str(token or "").split(".")
if len(parts) < 2:
return None
seg = parts[1].replace("-", "+").replace("_", "/")
seg += "=" * ((4 - len(seg) % 4) % 4)
data = json.loads(base64.b64decode(seg).decode("utf-8", "replace"))
return data if isinstance(data, dict) else None
except Exception:
return None
def enrich_probe_account(acc: dict) -> dict:
a = acc or {}
payload = decode_jwt_payload(a.get("accessToken")) or {}
auth = payload.get("https://api.openai.com/auth") or {}
plan_raw = str(auth.get("chatgpt_plan_type") or "").strip()
# 实时 accounts/check 检测得到的套餐(livePlanType / 存储的 planType)权威;
# JWT 里的 chatgpt_plan_type 常年过期(PLUS 账号也可能显示 free),仅作兜底。
effective_plan = str(a.get("livePlanType") or a.get("planType") or "").strip() or plan_raw
exp = int(payload.get("exp") or 0) if payload else 0
now = int(time.time())
stored = a.get("status") if a.get("status") in ("valid", "invalid", "unknown") else "unknown"
token_expired = (exp <= now) if exp else None
remaining = (exp - now) if exp else None
# 展示状态:ok / expiring / invalid / unknown(源于 JWT exp,尊重刷新失败标记的 invalid)
if stored == "invalid":
display = "invalid"
elif exp:
display = "ok" if remaining > PROBE_TOKEN_NEAR_EXP_SEC else "expiring"
elif stored == "valid":
display = "ok"
else:
display = "unknown"
return {
"id": a.get("id"),
"email": a.get("email") or "",
"password": a.get("password") or "",
"twoFactorSecret": a.get("twoFactorSecret") or "",
"accessToken": a.get("accessToken") or "",
"refreshToken": a.get("refreshToken") or "",
"clientId": a.get("clientId") or "",
"chatgptAccountId": a.get("chatgptAccountId") or auth.get("chatgpt_account_id") or "",
"planType": effective_plan or "unknown",
"planLabel": effective_plan.upper() if effective_plan else "未知",
"tokenExp": exp,
"tokenExpired": token_expired,
"tokenExpiresInSec": remaining,
"status": display,
"rawStatus": stored,
"hasAccess": bool(a.get("accessToken")),
"hasRefresh": bool(a.get("refreshToken")),
# SUB / sub2api 第二阶段授权是否成功(用于账号列表的"SUB"列)
"subOk": bool(a.get("subOk")),
# 批量直开(card-flow)开通成功的标记(pool-mark-opened 写入)
"opened": bool(a.get("opened")),
"activatedAt": a.get("activatedAt"),
"note": a.get("note") or "",
"lastCheckedAt": a.get("lastCheckedAt") or 0,
"createdAt": a.get("createdAt") or 0,
}
# ---------- 代理:从池里取一个(round-robin),转成 httpx 代理 URL ----------
def _proxy_url_from(proxy: dict):
if not proxy or not proxy.get("host") or not proxy.get("port"):
return None
user = proxy.get("username")
if user:
from urllib.parse import quote as _q
auth = f"{_q(str(user))}:{_q(str(proxy.get('password') or ''))}@"
else:
auth = ""
return f"http://{auth}{proxy['host']}:{proxy['port']}"
def _pick_proxy_url():
proxies = load_proxies()
ok = [p for p in proxies if p.get("status") == "ok"]
pool = ok if ok else proxies
if not pool:
return None
with _probe_lock:
idx = _probe_rr["i"] % len(pool)
_probe_rr["i"] += 1
return _proxy_url_from(pool[idx])
# ---------- 核心:refresh_token 换 access_token(普通 HTTPS,忠实复刻 probe.js refreshOpenAiTokens----------
def refresh_openai_tokens(refresh_token: str, proxy_url: str | None = None) -> dict:
if not refresh_token:
return {"ok": False, "accessToken": "", "refreshToken": "", "error": "missing_refresh_token", "httpStatus": 0}
if httpx is None:
return {"ok": False, "accessToken": "", "refreshToken": "", "error": "httpx_unavailable", "httpStatus": 0}
data = {
"grant_type": "refresh_token",
"client_id": OPENAI_CLIENT_ID,
"refresh_token": refresh_token,
"scope": OPENAI_SCOPE,
}
headers = {"content-type": "application/x-www-form-urlencoded", "user-agent": CHROME_UA}
try:
client_kwargs = {"timeout": 30.0, "headers": headers}
if proxy_url:
client_kwargs["proxy"] = proxy_url
with httpx.Client(**client_kwargs) as client:
r = client.post(OPENAI_TOKEN_URL, data=data)
try:
body = r.json()
except Exception:
body = {}
if r.status_code == 200 and body.get("access_token"):
return {
"ok": True,
"accessToken": str(body.get("access_token") or ""),
"refreshToken": str(body.get("refresh_token") or refresh_token or ""),
"error": "",
"httpStatus": r.status_code,
}
err = str(body.get("error") or body.get("error_description") or f"HTTP {r.status_code}")
return {"ok": False, "accessToken": "", "refreshToken": "", "error": err, "httpStatus": r.status_code}
except Exception as exc: # noqa: BLE001
return {"ok": False, "accessToken": "", "refreshToken": "", "error": str(exc), "httpStatus": 0}
def _refresh_with_fallback(refresh_token: str) -> dict:
"""先经代理池刷新;若因区域被 403(unsupported_country...) 拦截,则直连再试一次。
auto 原实现是直连(无代理),这里保留“代理池优先 + 直连兜底”以兼顾区域限制。"""
proxy_url = _pick_proxy_url()
r = refresh_openai_tokens(refresh_token, proxy_url)
if (not r.get("ok")) and proxy_url and r.get("httpStatus") == 403 \
and re.search(r"unsupported_country|region|territory", r.get("error") or "", re.I):
r2 = refresh_openai_tokens(refresh_token, None) # 直连兜底
if r2.get("ok") or r2.get("httpStatus") not in (0, 403):
return r2
return r
# ---------- 实时套餐校验:用 access_token 调 accounts/checkcurl_cffi 子进程)----------
def _plan_check_once(access_token: str, account_id: str, proxy_url: str | None) -> dict:
payload = {"access_token": access_token, "account_id": account_id or "", "proxy": proxy_url or "DIRECT"}
try:
proc = subprocess.run(
[PY_BIN, PLAN_CHECK_SCRIPT],
input=json.dumps(payload),
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
timeout=90,
)
except FileNotFoundError:
return {"ok": False, "httpStatus": 0, "error": f"未找到 Python 解释器:{PY_BIN}"}
except subprocess.TimeoutExpired:
return {"ok": False, "httpStatus": 0, "error": "套餐校验超时"}
out = (proc.stdout or "").strip()
if not out:
return {"ok": False, "httpStatus": 0, "error": (proc.stderr or "no output")[:300]}
try:
return json.loads(out.splitlines()[-1])
except Exception:
return {"ok": False, "httpStatus": 0, "error": out[:300]}
def _live_plan_check(access_token: str, account_id: str) -> dict:
"""先经代理池校验;若区域 403 被拦,直连兜底再试一次。"""
proxy_url = _pick_proxy_url()
r = _plan_check_once(access_token, account_id, proxy_url)
if (not r.get("ok")) and proxy_url and (
r.get("httpStatus") in (403, 0)
or re.search(r"unsupported_country|region|territory", str(r.get("error") or ""), re.I)
):
r2 = _plan_check_once(access_token, account_id, None) # 直连兜底
if r2.get("ok"):
return r2
return r
def _refresh_probe_by_id(acc_id: str) -> dict:
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None)
if not acc:
return {"ok": False, "notFound": True, "error": "账号不存在"}
if not acc.get("refreshToken"):
acc["status"] = "invalid"
acc["lastCheckedAt"] = int(time.time() * 1000)
save_probe(items)
return {"ok": False, "error": "缺少 refreshToken", "account": enrich_probe_account(acc)}
rt = acc["refreshToken"]
r = _refresh_with_fallback(rt)
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None)
if not acc:
return {"ok": False, "notFound": True, "error": "账号不存在"}
acc["lastCheckedAt"] = int(time.time() * 1000)
if r.get("ok") and r.get("accessToken"):
acc["accessToken"] = r["accessToken"]
if r.get("refreshToken"):
acc["refreshToken"] = r["refreshToken"]
acc["status"] = "valid"
save_probe(items)
return {"ok": True, "account": enrich_probe_account(acc)}
invalid = r.get("httpStatus") == 401 or re.search(r"invalid_grant|invalid_token|unauthorized", r.get("error") or "", re.I)
acc["status"] = "invalid" if invalid else (acc.get("status") or "unknown")
save_probe(items)
return {"ok": False, "error": r.get("error") or "refresh_failed",
"httpStatus": r.get("httpStatus"), "account": enrich_probe_account(acc)}
def _run_probe_auto_refresh() -> dict:
with _probe_auto_lock:
if _probe_auto["running"]:
return _probe_auto["lastResult"]
_probe_auto["running"] = True
summary = {"scanned": 0, "refreshed": 0, "skipped": 0, "failed": 0, "invalid": 0}
try:
snapshot = load_probe()
now = int(time.time())
summary["scanned"] = len(snapshot)
updates: dict = {} # id -> 待写回字段(仅按 id 合并,避免覆盖并发删除/编辑)
for acc in snapshot:
try:
if not acc.get("refreshToken"):
summary["skipped"] += 1
continue
payload = decode_jwt_payload(acc.get("accessToken"))
exp = int(payload.get("exp") or 0) if payload else 0
status = str(acc.get("status") or "unknown")
need = status == "unknown" or not exp or exp <= now + PROBE_TOKEN_NEAR_EXP_SEC
if not need:
summary["skipped"] += 1
continue
r = _refresh_with_fallback(acc["refreshToken"])
upd = {"lastCheckedAt": int(time.time() * 1000)}
if r.get("ok") and r.get("accessToken"):
upd["accessToken"] = r["accessToken"]
if r.get("refreshToken"):
upd["refreshToken"] = r["refreshToken"]
upd["status"] = "valid"
summary["refreshed"] += 1
else:
invalid = r.get("httpStatus") == 401 or re.search(r"invalid_grant|invalid_token|unauthorized", r.get("error") or "", re.I)
if invalid:
upd["status"] = "invalid"
summary["invalid"] += 1
else:
summary["failed"] += 1
updates[str(acc.get("id"))] = upd
except Exception:
summary["failed"] += 1
# 按 id 合并写回当前存储(重新加载,跳过期间已被删除的账号)
with _probe_lock:
items = load_probe()
by_id = {str(a.get("id")): a for a in items}
for acc_id, upd in updates.items():
target = by_id.get(acc_id)
if target is not None:
target.update(upd)
save_probe(items)
with _probe_auto_lock:
_probe_auto["lastRunAt"] = int(time.time() * 1000)
_probe_auto["lastResult"] = summary
return summary
finally:
with _probe_auto_lock:
_probe_auto["running"] = False
def _auto_refresh_state() -> dict:
s = load_settings()
with _probe_auto_lock:
return {
"enabled": s.get("probeTokenAutoRefresh", True) is not False,
"intervalMin": _clamp_int(s.get("probeTokenAutoRefreshMin"), 5, 1440, 15),
"lastRunAt": _probe_auto["lastRunAt"],
"lastResult": _probe_auto["lastResult"],
"running": _probe_auto["running"],
}
def _auto_refresh_loop():
time.sleep(8) # 启动后稍晚跑一轮,避免拖慢 listen(对齐 auto
while True:
try:
state = _auto_refresh_state()
if state["enabled"]:
_run_probe_auto_refresh()
interval = _auto_refresh_state()["intervalMin"]
except Exception:
interval = 15
_probe_auto_wake.wait(timeout=interval * 60)
_probe_auto_wake.clear()
# ---------- GET /api/probe/accounts ----------
@bp.get("/api/probe/accounts")
def api_probe_list():
items = load_probe()
return jsonify({
"accounts": [enrich_probe_account(a) for a in items],
"autoRefresh": _auto_refresh_state(),
})
# ---------- POST /api/probe/accounts/import ----------
@bp.post("/api/probe/accounts/import")
def api_probe_import():
body = request.get_json(silent=True) or {}
parsed = parse_sub2api(body.get("text") or "")
with _probe_lock:
items = load_probe()
by_email = {str(a.get("email") or "").lower(): a for a in items if a.get("email")}
by_at = {str(a.get("accessToken")): a for a in items if a.get("accessToken")}
by_rt = {str(a.get("refreshToken")): a for a in items if a.get("refreshToken")}
added = 0
updated = 0
for item in parsed:
key = str(item.get("email") or "").lower()
# 有邮箱按邮箱去重;无邮箱(裸AT/仅RT)按 accessToken / refreshToken 去重
existing = by_email.get(key) if key else None
if existing is None and not key:
if item.get("accessToken"):
existing = by_at.get(str(item.get("accessToken")))
if existing is None and item.get("refreshToken"):
existing = by_rt.get(str(item.get("refreshToken")))
if existing:
existing["accessToken"] = item.get("accessToken") or existing.get("accessToken") or ""
existing["refreshToken"] = item.get("refreshToken") or existing.get("refreshToken") or ""
if item.get("password"):
existing["password"] = item["password"]
if item.get("clientId"):
existing["clientId"] = item["clientId"]
existing["chatgptAccountId"] = item.get("chatgptAccountId") or existing.get("chatgptAccountId") or ""
existing["planType"] = item.get("planType") or existing.get("planType") or ""
existing["status"] = "unknown"
existing["lastCheckedAt"] = 0
updated += 1
else:
rec = {
"id": str(uuid.uuid4()),
"email": item.get("email") or "",
"password": item.get("password") or "",
"clientId": item.get("clientId") or "",
"refreshToken": item.get("refreshToken") or "",
"accessToken": item.get("accessToken") or "",
"chatgptAccountId": item.get("chatgptAccountId") or "",
"planType": item.get("planType") or "",
"note": "",
"status": "unknown",
"lastCheckedAt": 0,
"createdAt": int(time.time() * 1000),
}
items.append(rec)
if key:
by_email[key] = rec
if rec.get("accessToken"):
by_at[str(rec["accessToken"])] = rec
if rec.get("refreshToken"):
by_rt[str(rec["refreshToken"])] = rec
added += 1
save_probe(items)
total = len(items)
# 导入后触发未知状态账号的自动刷新(若开启)
if (added + updated) > 0 and _auto_refresh_state()["enabled"]:
threading.Thread(target=_run_probe_auto_refresh, name="probe-import-refresh", daemon=True).start()
return jsonify({"added": added, "updated": updated, "parsed": len(parsed), "total": total})
# ---------- PUT /api/probe/accounts(编辑)----------
@bp.put("/api/probe/accounts")
def api_probe_edit():
body = request.get_json(silent=True) or {}
acc_id = str(body.get("id") or "")
if not acc_id:
return jsonify({"error": "缺少 id"}), 400
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == acc_id), None)
if not acc:
return jsonify({"error": "账号不存在"}), 404
if body.get("email") is not None:
acc["email"] = str(body.get("email") or "").strip()
if body.get("accessToken") is not None:
acc["accessToken"] = str(body.get("accessToken") or "").strip()
if body.get("refreshToken") is not None:
acc["refreshToken"] = str(body.get("refreshToken") or "").strip()
if body.get("chatgptAccountId") is not None:
acc["chatgptAccountId"] = str(body.get("chatgptAccountId") or "").strip()
if body.get("password") is not None:
acc["password"] = str(body.get("password") or "")
if body.get("twoFactorSecret") is not None:
acc["twoFactorSecret"] = str(body.get("twoFactorSecret") or "").strip()
if body.get("note") is not None:
acc["note"] = str(body.get("note") or "")
# 编辑后:可解 JWT 且原为 invalid 则回到 unknown 等待下次校验
payload = decode_jwt_payload(acc.get("accessToken"))
if payload and payload.get("exp"):
acc["status"] = "unknown" if acc.get("status") == "invalid" else (acc.get("status") or "unknown")
save_probe(items)
enriched = enrich_probe_account(acc)
return jsonify({"account": enriched})
# ---------- POST /api/probe/accounts/refresh(刷新一个)----------
@bp.post("/api/probe/accounts/refresh")
def api_probe_refresh():
body = request.get_json(silent=True) or {}
acc_id = str(body.get("id") or "")
if not acc_id:
return jsonify({"error": "缺少 id"}), 400
result = _refresh_probe_by_id(acc_id)
if not result.get("ok") and result.get("notFound"):
return jsonify({"error": "账号不存在"}), 404
return jsonify(result)
# ---------- POST /api/probe/accounts/detect(用 access_token 实时查真实套餐 FREE/PLUS/PRO----------
# 优先实时调用 chatgpt.com/backend-api/accounts/checkcurl_cffi 子进程,读 plan_type +
# has_active_subscription)——这是账号“当前真实级别”,而非 AT 内 JWT 的历史标记。
# 若 AT 已过期且有 refreshToken,先刷新拿到最新 AT 再校验;实时校验失败(区域/被拦/无网络)时
# 回退用 AT 内 JWT 字段,并在返回中标注 source=jwt 与失败原因,绝不谎报为实时结果。
@bp.post("/api/probe/accounts/detect")
def api_probe_detect():
body = request.get_json(silent=True) or {}
acc_id = str(body.get("id") or "")
if not acc_id:
return jsonify({"error": "缺少 id"}), 400
with _probe_lock:
acc = next((a for a in load_probe() if str(a.get("id")) == acc_id), None)
if acc is None:
return jsonify({"error": "账号不存在"}), 404
has_rt = bool(acc.get("refreshToken"))
payload = decode_jwt_payload(acc.get("accessToken"))
exp = int(payload.get("exp") or 0) if payload else 0
# AT 过期且有 refreshToken:先刷新,拿到可用于实时校验的最新 AT
refreshed = False
if has_rt and (not exp or exp <= int(time.time())):
refreshed = bool(_refresh_probe_by_id(acc_id).get("ok"))
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == acc_id), None)
if acc is None:
return jsonify({"error": "账号不存在"}), 404
access_token = str(acc.get("accessToken") or "")
account_id = str(acc.get("chatgptAccountId") or "")
if not access_token:
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == acc_id), None)
pub = enrich_probe_account(acc) if acc else {}
return jsonify({"ok": False, "error": "缺少 Access Token,无法检测套餐", "account": pub})
# 实时 accounts/check
live = _live_plan_check(access_token, account_id)
if live.get("ok") and (live.get("plan") or live.get("active")):
plan = str(live.get("plan") or ("plus" if live.get("active") else "")).strip().lower()
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == acc_id), None)
if acc is None:
return jsonify({"error": "账号不存在"}), 404
if plan:
acc["planType"] = plan
acc["livePlanType"] = plan # 实时检测权威值,enrich 优先使用,避免被 JWT/再导入覆盖
if live.get("accountId") and not acc.get("chatgptAccountId"):
acc["chatgptAccountId"] = live.get("accountId")
acc["lastCheckedAt"] = int(time.time() * 1000)
pub = enrich_probe_account(acc)
save_probe(items)
return jsonify({
"ok": True,
"source": "live",
"refreshed": refreshed,
"active": bool(live.get("active")),
"planType": pub.get("planType"),
"planLabel": pub.get("planLabel"),
"account": pub,
})
# 实时失败 → 回退 JWT,如实标注
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == acc_id), None)
if acc is None:
return jsonify({"error": "账号不存在"}), 404
pub = enrich_probe_account(acc)
acc["planType"] = pub.get("planType")
acc["lastCheckedAt"] = int(time.time() * 1000)
save_probe(items)
reason = str(live.get("error") or "").strip()
if live.get("httpStatus"):
reason = f"HTTP {live.get('httpStatus')} {reason}".strip()
return jsonify({
"ok": True,
"source": "jwt",
"refreshed": refreshed,
"liveError": reason or "实时校验失败",
"planType": pub.get("planType"),
"planLabel": pub.get("planLabel"),
"account": pub,
})
# ---------- 批量检测:≤5 线程并发跑实时套餐校验 ----------
def _probe_detect_once(acc_id: str) -> bool:
"""对单个账号跑实时 accounts/check(代理→直连兜底),写回 planType/livePlanType。
per-account 异常不抛出(由 _safe_detect_one 兜底),返回是否成功检测到套餐。"""
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None)
if not acc:
return False
access_token = str(acc.get("accessToken") or "")
account_id = str(acc.get("chatgptAccountId") or "")
if not access_token:
return False
live = _live_plan_check(access_token, account_id) # 不持锁跑子进程,允许并发
with _probe_lock:
items = load_probe()
acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None)
if not acc:
return False
acc["lastCheckedAt"] = int(time.time() * 1000)
if live.get("ok") and (live.get("plan") or live.get("active")):
plan = str(live.get("plan") or ("plus" if live.get("active") else "")).strip().lower()
if plan:
acc["planType"] = plan
acc["livePlanType"] = plan
if live.get("accountId") and not acc.get("chatgptAccountId"):
acc["chatgptAccountId"] = live.get("accountId")
save_probe(items)
return True
save_probe(items)
return False
def _safe_detect_one(acc_id: str) -> bool:
try:
return _probe_detect_once(acc_id)
except Exception:
return False
def _run_probe_detect_batch(ids: list) -> None:
from concurrent.futures import ThreadPoolExecutor, as_completed
with _probe_detect_lock:
_probe_detect.update({"running": True, "total": len(ids), "done": 0, "ok": 0, "fail": 0,
"startedAt": int(time.time() * 1000), "finishedAt": 0})
try:
with ThreadPoolExecutor(max_workers=5) as ex:
futs = [ex.submit(_safe_detect_one, i) for i in ids]
for fut in as_completed(futs):
ok = False
try:
ok = bool(fut.result())
except Exception:
ok = False
with _probe_detect_lock:
_probe_detect["done"] += 1
if ok:
_probe_detect["ok"] += 1
else:
_probe_detect["fail"] += 1
finally:
with _probe_detect_lock:
_probe_detect["running"] = False
_probe_detect["finishedAt"] = int(time.time() * 1000)
# ---------- POST /api/probe/accounts/detect-batch(选中或全部,≤5 线程)----------
@bp.post("/api/probe/accounts/detect-batch")
def api_probe_detect_batch():
body = request.get_json(silent=True) or {}
ids = body.get("ids")
with _probe_lock:
items = load_probe()
if ids:
idset = {str(x) for x in ids}
targets = [str(a.get("id")) for a in items if str(a.get("id")) in idset and a.get("accessToken")]
else:
targets = [str(a.get("id")) for a in items if a.get("accessToken")]
with _probe_detect_lock:
if _probe_detect["running"]:
return jsonify({"ok": False, "error": "批量检测正在进行中", "status": dict(_probe_detect)}), 409
if not targets:
return jsonify({"ok": False, "error": "没有可检测的账号(需有 Access Token"}), 400
threading.Thread(target=_run_probe_detect_batch, args=(targets,), name="probe-detect-batch", daemon=True).start()
return jsonify({"ok": True, "total": len(targets)})
# ---------- GET /api/probe/accounts/detect-batch/status(进度轮询)----------
@bp.get("/api/probe/accounts/detect-batch/status")
def api_probe_detect_batch_status():
with _probe_detect_lock:
return jsonify(dict(_probe_detect))
# ---------- GET/POST /api/probe/accounts/auto-refresh(开关 + 间隔)----------
@bp.get("/api/probe/accounts/auto-refresh")
def api_probe_auto_get():
return jsonify(_auto_refresh_state())
@bp.post("/api/probe/accounts/auto-refresh")
def api_probe_auto_post():
body = request.get_json(silent=True) or {}
s = load_settings()
enabled = body.get("enabled")
s["probeTokenAutoRefresh"] = enabled is not False and enabled != "false"
if body.get("intervalMin") is not None:
s["probeTokenAutoRefreshMin"] = _clamp_int(body.get("intervalMin"), 5, 1440, 15)
save_settings(s)
_probe_auto_wake.set() # 唤醒调度线程,使新配置立即生效
if body.get("runNow"):
threading.Thread(target=_run_probe_auto_refresh, name="probe-run-now", daemon=True).start()
return jsonify(_auto_refresh_state())
# ---------- DELETE /api/probe/accountsids / all----------
@bp.delete("/api/probe/accounts")
def api_probe_delete():
body = request.get_json(silent=True) or {}
with _probe_lock:
items = load_probe()
before = len(items)
if body.get("all"):
items = []
else:
ids = {str(i) for i in (body.get("ids") or [])}
items = [a for a in items if str(a.get("id")) not in ids]
save_probe(items)
total = len(items)
return jsonify({"removed": before - total, "total": total})
# ---------- POST /api/probe/accounts/export-at(批量导出 AT,一行一个)----------
@bp.post("/api/probe/accounts/export-at")
def api_probe_export_at():
body = request.get_json(silent=True) or {}
ids = body.get("ids")
items = load_probe()
base = [a for a in items if str(a.get("id")) in {str(i) for i in ids}] if ids else items
tokens = [str(a.get("accessToken") or "").strip() for a in base]
tokens = [t for t in tokens if t]
ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime())
return Response(
"\n".join(tokens) + ("\n" if tokens else ""),
mimetype="text/plain",
headers={"Content-Disposition": f'attachment; filename="probe_at_export_{ts}.txt"',
"X-Export-Count": str(len(tokens))},
)
# ---------- POST /api/probe/accounts/export-sub(批量导出 SUB2API JSON----------
@bp.post("/api/probe/accounts/export-sub")
def api_probe_export_sub():
body = request.get_json(silent=True) or {}
ids = body.get("ids")
items = load_probe()
base = [a for a in items if str(a.get("id")) in {str(i) for i in ids}] if ids else items
# 有第二阶段 SUB 令牌的用 SUB 令牌导出;否则回退第一阶段 refreshTokenclientId 用 sub2api 默认常量)。
with_rt = [a for a in base if str(a.get("subRefreshToken") or a.get("refreshToken") or "").strip()]
accounts = []
for a in with_rt:
email = a.get("email") or ""
sub_rt = str(a.get("subRefreshToken") or "").strip()
if sub_rt:
credentials = {"refresh_token": sub_rt, "client_id": a.get("subClientId") or SUB2API_DEFAULT_CLIENT_ID, "email": email}
if a.get("subAccessToken"):
credentials["access_token"] = a.get("subAccessToken")
else:
credentials = {"refresh_token": a.get("refreshToken"), "client_id": SUB2API_DEFAULT_CLIENT_ID, "email": email}
if a.get("accessToken"):
credentials["access_token"] = a.get("accessToken")
if a.get("chatgptAccountId"):
credentials["chatgpt_account_id"] = a.get("chatgptAccountId")
accounts.append({
"name": email, "platform": "openai", "type": "oauth",
"credentials": credentials, "extra": {"email": email},
"concurrency": 1, "priority": 1, "rate_multiplier": 1, "auto_pause_on_expired": True,
})
payload = {
"type": "sub2api-data", "version": 1,
"exported_at": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
"proxies": [], "accounts": accounts,
}
ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime())
return Response(
json.dumps(payload, ensure_ascii=False, indent=2),
mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="sub2api_export_{ts}.json"',
"X-Export-Count": str(len(accounts))},
)
# ---------- POST /api/probe/accounts/export-pw(批量导出账密,一行一个 email----password----------
@bp.post("/api/probe/accounts/export-pw")
def api_probe_export_pw():
body = request.get_json(silent=True) or {}
ids = body.get("ids")
items = load_probe()
base = [a for a in items if str(a.get("id")) in {str(i) for i in ids}] if ids else items
# 首行表头 + 每行 email----password----twoFactorSecret(字段缺失留空但保留分隔符)。
lines = ["账号----密码----2FA"]
for a in base:
email = str(a.get("email") or "").strip()
if not email:
continue
pw = str(a.get("password") or "").strip()
tfa = str(a.get("twoFactorSecret") or "").strip()
lines.append(f"{email}----{pw}----{tfa}")
ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime())
return Response(
"\n".join(lines) + "\n",
mimetype="text/plain",
headers={"Content-Disposition": f'attachment; filename="probe_pw_export_{ts}.txt"',
"X-Export-Count": str(max(0, len(lines) - 1))}, # 数据行数(不含表头)
)
# ==========================================================================
# 设置:代理池 + 接码/SMS 配置的读写(供页面「设置」弹窗使用)
# ==========================================================================
def _proxy_to_line(p: dict) -> str:
if p.get("raw"):
return str(p["raw"])
host = p.get("host") or ""
port = p.get("port") or ""
user = p.get("username") or ""
pw = p.get("password") or ""
if user:
return f"{host}:{port}:{user}:{pw}"
return f"{host}:{port}"
def _parse_proxy_line(line: str):
line = line.strip()
if not line:
return None
# 支持 scheme://user:pass@host:port
m = re.match(r"^(?:(\w+)://)?(?:([^:@/]+):([^@/]*)@)?([^:/@]+):(\d+)$", line)
if m:
_scheme, u1, p1, host, port = m.groups()
rec = {"raw": line, "host": host, "port": int(port)}
if u1:
rec["username"] = u1
rec["password"] = p1 or ""
return rec
# 支持 host:port:user:pass
parts = line.split(":")
if len(parts) == 2:
return {"raw": line, "host": parts[0], "port": int(parts[1])} if parts[1].isdigit() else None
if len(parts) >= 4 and parts[1].isdigit():
return {"raw": line, "host": parts[0], "port": int(parts[1]), "username": parts[2], "password": ":".join(parts[3:])}
return None
@bp.get("/api/account-config")
def api_account_config_get():
proxies = load_proxies()
settings = load_settings()
sms = settings.get("sms") or {}
provider = sms.get("provider") or "smscode"
prov_cfg = sms.get(provider) or {}
mb = load_mailbox()
def _sms_view(cfg: dict) -> dict:
cfg = cfg or {}
country = cfg.get("country")
if country in (None, ""):
country = cfg.get("countryId")
return {
"apiKey": cfg.get("apiKey") or "",
"baseUrl": cfg.get("baseUrl") or "",
"country": "" if country in (None, "") else country,
"maxPrice": cfg.get("maxPrice") or "",
"service": cfg.get("service") or "",
}
return jsonify({
"proxies": [_proxy_to_line(p) for p in proxies],
"proxyCount": len(proxies),
"sms": {
"provider": provider,
"pollTimeout": sms.get("pollTimeout") or 180000,
**_sms_view(prov_cfg),
},
"smsAll": {p: _sms_view(sms.get(p) or {}) for p in ("smscode", "smsbower", "herosms")},
"providers": ["smscode", "smsbower", "herosms"],
"mailbox": {
"entries": [_mailbox_public(e) for e in mb.get("entries", [])],
"count": len(mb.get("entries", [])),
"unused": sum(1 for e in mb.get("entries", []) if not e.get("used")),
"split": bool(mb.get("split")),
},
# 浏览器注册(CloakBrowser)设置:license key + 固定注册密码(与 proxy/sms/mailbox 同一套 settings.json 持久化)。
"cloak": {
"licenseKey": settings.get("cloakLicenseKey") or "",
"regPassword": settings.get("regPassword") or "",
},
})
@bp.post("/api/account-config")
def api_account_config_post():
body = request.get_json(silent=True) or {}
result = {"proxySaved": False, "smsSaved": False}
# 代理池:支持传字符串(一行一个)或已解析数组
if "proxies" in body:
raw = body.get("proxies")
lines = raw.split("\n") if isinstance(raw, str) else [str(x) for x in (raw or [])]
parsed = []
bad = []
for ln in lines:
ln = ln.strip()
if not ln:
continue
rec = _parse_proxy_line(ln)
if rec:
parsed.append(rec)
else:
bad.append(ln)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
PROXY_FILE.write_text(json.dumps(parsed, ensure_ascii=False, indent=2), encoding="utf-8")
result["proxySaved"] = True
result["proxyCount"] = len(parsed)
result["proxyBad"] = bad
# 接码/SMS 配置
if "sms" in body and isinstance(body["sms"], dict):
sms_in = body["sms"]
settings = load_settings()
sms = settings.get("sms") or {}
provider = sms_in.get("provider") if sms_in.get("provider") in ("smscode", "smsbower", "herosms") else (sms.get("provider") or "smscode")
sms["provider"] = provider
if sms_in.get("pollTimeout") is not None:
try:
sms["pollTimeout"] = int(sms_in["pollTimeout"])
except (TypeError, ValueError):
pass
prov_cfg = dict(sms.get(provider) or {})
if sms_in.get("apiKey") is not None:
prov_cfg["apiKey"] = str(sms_in.get("apiKey") or "")
if sms_in.get("baseUrl"):
prov_cfg["baseUrl"] = str(sms_in.get("baseUrl"))
if sms_in.get("country") is not None:
raw_country = str(sms_in.get("country") or "").strip()
country_val: object = ""
if raw_country:
country_val = int(raw_country) if raw_country.isdigit() else raw_country
# smscode 用 countryId,其余(smsbower/herosms)用 country
if provider == "smscode":
prov_cfg["countryId"] = country_val
else:
prov_cfg["country"] = country_val
if sms_in.get("maxPrice") is not None:
prov_cfg["maxPrice"] = str(sms_in.get("maxPrice") or "").strip()
sms[provider] = prov_cfg
settings["sms"] = sms
save_settings(settings)
result["smsSaved"] = True
# 邮箱池:这里只更新 split 开关,条目增改删走 /api/mailbox/* 独立接口(避免清掉 used 状态)
if "mailbox" in body and isinstance(body["mailbox"], dict):
mb = load_mailbox()
mb["split"] = bool(body["mailbox"].get("split"))
save_mailbox(mb)
result["mailboxSaved"] = True
# 浏览器注册(CloakBrowserlicense key:存到 settings.json 的 cloakLicenseKey
# 供 cloak_reg_runner.mjs 启动隐身 Chromium 时使用(Pro 用最新二进制)。
if "cloak" in body and isinstance(body["cloak"], dict):
settings = load_settings()
if body["cloak"].get("licenseKey") is not None:
settings["cloakLicenseKey"] = str(body["cloak"].get("licenseKey") or "").strip()
# 固定注册密码:两种注册模式共用(reg_password.mjs 优先读 settings.regPassword);
# 留空则清空,运行时回退到内置默认强口令。
if body["cloak"].get("regPassword") is not None:
settings["regPassword"] = str(body["cloak"].get("regPassword") or "").strip()
save_settings(settings)
result["cloakSaved"] = True
return jsonify(result)
# ---------- 邮箱池条目管理 ----------
@bp.post("/api/mailbox/import")
def api_mailbox_import():
body = request.get_json(silent=True) or {}
parsed = parse_mailbox_lines(body.get("text") or "")
with _plus_lock:
mb = load_mailbox()
entries, added = merge_mailbox_entries(mb.get("entries", []), parsed)
mb["entries"] = entries
save_mailbox(mb)
return jsonify({
"added": added,
"parsed": len(parsed),
"count": len(entries),
"unused": sum(1 for e in entries if not e.get("used")),
"entries": [_mailbox_public(e) for e in entries],
})
@bp.post("/api/mailbox/update")
def api_mailbox_update():
body = request.get_json(silent=True) or {}
email = str(body.get("email") or "").strip().lower()
if not email:
return jsonify({"ok": False, "error": "缺少邮箱"}), 400
with _plus_lock:
mb = load_mailbox()
e = next((x for x in mb.get("entries", []) if x.get("email", "").lower() == email), None)
if not e:
return jsonify({"ok": False, "error": "邮箱不存在"}), 404
if body.get("credential") is not None:
cred = str(body.get("credential") or "").strip()
if cred:
e["credential"] = cred
e["type"] = "api" if re.match(r"^https?://", cred, re.I) else "imap"
if body.get("used") is not None:
e["used"] = bool(body.get("used"))
save_mailbox(mb)
return jsonify({"ok": True, "entry": _mailbox_public(e)})
@bp.post("/api/mailbox/split-generate")
def api_mailbox_split_generate():
"""为每个主邮箱(local 不含 +)生成 count 个 local+XXXX 别名,复用主邮箱凭证,并入列表。"""
body = request.get_json(silent=True) or {}
try:
count = max(1, min(200, int(body.get("count") or 10)))
except (TypeError, ValueError):
count = 10
with _plus_lock:
mb = load_mailbox()
entries = mb.get("entries", [])
existing = {e.get("email", "").lower() for e in entries}
added = 0
for e in list(entries):
email = e.get("email", "")
if "@" not in email:
continue
local, _, domain = email.partition("@")
if "+" in local:
continue # 已是别名不再分裂
for _ in range(count):
alias = ""
for _try in range(50):
cand = f"{local}+{uuid.uuid4().hex[:4]}@{domain}"
if cand.lower() not in existing:
alias = cand
break
if not alias:
continue
existing.add(alias.lower())
entries.append({
"email": alias,
"credential": e.get("credential", ""),
"type": e.get("type", "imap"),
"used": False,
"alias_of": email,
})
added += 1
mb["entries"] = entries
save_mailbox(mb)
return jsonify({
"added": added,
"count": len(entries),
"unused": sum(1 for e in entries if not e.get("used")),
"entries": [_mailbox_public(e) for e in entries],
})
@bp.post("/api/mailbox/delete")
def api_mailbox_delete():
body = request.get_json(silent=True) or {}
targets = {str(x).strip().lower() for x in (body.get("emails") or [])}
one = str(body.get("email") or "").strip().lower()
if one:
targets.add(one)
if not targets:
return jsonify({"ok": False, "error": "缺少邮箱"}), 400
with _plus_lock:
mb = load_mailbox()
before = len(mb.get("entries", []))
mb["entries"] = [e for e in mb.get("entries", []) if e.get("email", "").lower() not in targets]
save_mailbox(mb)
entries = mb["entries"]
return jsonify({
"ok": True,
"removed": before - len(entries),
"count": len(entries),
"unused": sum(1 for e in entries if not e.get("used")),
"entries": [_mailbox_public(e) for e in entries],
})
@bp.post("/api/mailbox/clear")
def api_mailbox_clear():
"""清空邮箱池全部条目(前端已做主题化二次确认)。"""
with _plus_lock:
mb = load_mailbox()
removed = len(mb.get("entries", []))
mb["entries"] = []
save_mailbox(mb)
return jsonify({"ok": True, "removed": removed, "count": 0, "unused": 0, "entries": []})
@bp.post("/api/sms/balance")
def api_sms_balance():
"""查询接码平台余额(smsbower / herosms 走 handler_api.php 的 getBalance)。"""
body = request.get_json(silent=True) or {}
settings = load_settings()
sms = settings.get("sms") or {}
provider = str(body.get("provider") or sms.get("provider") or "smsbower").strip()
prov_cfg = sms.get(provider) or {}
api_key = str(body.get("apiKey") or prov_cfg.get("apiKey") or "").strip()
base_url = str(body.get("baseUrl") or prov_cfg.get("baseUrl") or "").strip()
if not api_key:
return jsonify({"ok": False, "error": "未配置 API Key"})
if not base_url:
return jsonify({"ok": False, "error": "未配置 Base URL"})
if httpx is None:
return jsonify({"ok": False, "error": "httpx 不可用"})
try:
r = httpx.get(base_url, params={"api_key": api_key, "action": "getBalance"}, timeout=20)
text = (r.text or "").strip()
if text.startswith("ACCESS_BALANCE"):
bal = text.split(":", 1)[1].strip() if ":" in text else ""
return jsonify({"ok": True, "provider": provider, "balance": bal, "raw": text})
errmap = {
"BAD_KEY": "API 密钥不正确",
"BAD_ACTION": "动作错误",
"ERROR_SQL": "服务端错误,请稍后再试",
}
return jsonify({"ok": False, "error": errmap.get(text, text or f"HTTP {r.status_code}")})
except Exception as exc:
return jsonify({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
@bp.post("/api/mailbox/test")
def api_mailbox_test():
body = request.get_json(silent=True) or {}
email = str(body.get("email") or "").strip()
credential = str(body.get("credential") or "").strip()
if not credential:
for e in load_mailbox().get("entries", []):
if e.get("email", "").lower() == email.lower():
credential = e.get("credential", "")
break
if not _EMAIL_RE.match(email) or not credential:
return jsonify({"ok": False, "error": "缺少邮箱或凭证"}), 400
try:
if re.match(r"^https?://", credential, re.I):
return jsonify(_mailbox_test_api(email, credential))
return jsonify(_mailbox_test_imap(email, credential))
except Exception as exc:
return jsonify({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
# 启动 TOKEN 自动刷新后台调度线程(daemon)
_probe_auto_thread = threading.Thread(target=_auto_refresh_loop, name="probe-auto-refresh", daemon=True)
_probe_auto_thread.start()