mirror of
https://wget.la/https://github.com/432539/gpt
synced 2026-08-17 03:26:59 +08:00
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、 拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能 不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
2684 lines
114 KiB
Python
2684 lines
114 KiB
Python
"""账号管理(Account Management)—— 从 auto(Node) 项目移植的 `plus`(GPT 管理) 功能。
|
||
|
||
本模块是「加法式」的:仅新增一个 Flask Blueprint,挂到主 app(app.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 隐身 Chromium)runner —— 与 REG_RUNNER(HTTP 协议注册)并列,
|
||
# 由「注册方式」决定调用哪一个;两者输入/输出协议一致,故批量编排逻辑可共用。
|
||
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 直连)/ browser(CloakBrowser);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----credential;credential 为 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" |&|‌|&#\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("<", "<").replace(">", ">").replace("&", "&")
|
||
t = re.sub(r" |‌|&#\d+;", " ", t)
|
||
return re.sub(r"\s+", " ", t).strip()
|
||
|
||
|
||
def _parse_api_mailbox_html(html: str, limit: int = 15) -> list:
|
||
"""解析第三方取件 API 返回的 HTML(icloud-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/check(curl_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/check(curl_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/accounts(ids / 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 令牌导出;否则回退第一阶段 refreshToken(clientId 用 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
|
||
|
||
# 浏览器注册(CloakBrowser)license 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()
|