my-test / register.py
Looknicemm's picture
Upload 18 files
dbe3386 verified
Raw
History Blame Contribute Delete
45.7 kB
"""
register.py - ChatGPT 注册机 Web UI 后端库
将 chatgpt_register.py 核心逻辑以 library 形式暴露:
- log_callback: 替代 print,将日志路由到调用方
- stop_event: threading.Event,支持中止批量任务
- progress_callback: (success, fail, total) 进度回调
"""
from __future__ import annotations
import concurrent.futures
import io
import json
import logging
import os
import sys
import threading
import time
from urllib.parse import quote
from typing import Callable, Dict, List, Optional, Any
import requests as _requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
# ============================================================
# 基础路径
# ============================================================
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
POOL_SYNC_UPLOAD_WORKERS = 5
POOL_SYNC_DOWNLOAD_WORKERS = 5
POOL_TOKEN_UPLOAD_WORKERS = 5
# ============================================================
# stdout 捕获 → log_callback
# ============================================================
class _LogCapture(io.RawIOBase):
"""将 sys.stdout 写入重定向到 callback"""
def __init__(self, callback: Callable[[str], None]):
self._callback = callback
self._buf = ""
self._lock = threading.Lock()
def write(self, text: str) -> int: # type: ignore[override]
with self._lock:
self._buf += text
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
line = line.rstrip("\r")
if line and self._callback:
try:
self._callback(line)
except Exception:
pass
return len(text)
def flush(self):
pass
def readable(self):
return False
def writable(self):
return True
class _LoggingHandler(logging.Handler):
"""将 logging 模块输出路由到 callback"""
def __init__(self, callback: Callable[[str], None]):
super().__init__()
self._callback = callback
def emit(self, record):
try:
msg = self.format(record)
if self._callback:
self._callback(msg)
except Exception:
pass
# ============================================================
# 延迟导入 chatgpt_register(抑制初始化输出)
# ============================================================
_cr_lock = threading.Lock()
_cr = None
def _get_cr():
"""获取 chatgpt_register 模块(懒加载,抑制导入输出)"""
global _cr
if _cr is not None:
return _cr
with _cr_lock:
if _cr is not None:
return _cr
devnull = open(os.devnull, "w")
old_stdout = sys.stdout
sys.stdout = devnull
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"chatgpt_register",
os.path.join(_BASE_DIR, "chatgpt_register.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore
_cr = mod
finally:
sys.stdout = old_stdout
devnull.close()
return _cr
def _apply_config(mod, config: dict):
"""将 config dict 覆盖到 chatgpt_register 模块全局变量"""
if not config:
return
if "duckmail_bearer" in config:
mod.DUCKMAIL_BEARER = config["duckmail_bearer"]
if "duckmail_api_base" in config:
mod.DUCKMAIL_API_BASE = config["duckmail_api_base"].rstrip("/")
if "duckmail_suffix" in config:
mod.DUCKMAIL_SUFFIX = config["duckmail_suffix"]
if "enable_oauth" in config:
mod.ENABLE_OAUTH = mod._as_bool(config["enable_oauth"])
if "oauth_required" in config:
mod.OAUTH_REQUIRED = mod._as_bool(config["oauth_required"])
if "oauth_issuer" in config:
mod.OAUTH_ISSUER = config["oauth_issuer"].rstrip("/")
if "oauth_client_id" in config:
mod.OAUTH_CLIENT_ID = config["oauth_client_id"]
if "oauth_redirect_uri" in config:
mod.OAUTH_REDIRECT_URI = config["oauth_redirect_uri"]
if "ak_file" in config:
ak = config["ak_file"]
mod.AK_FILE = ak if os.path.isabs(ak) else os.path.join(_BASE_DIR, ak)
if "rk_file" in config:
rk = config["rk_file"]
mod.RK_FILE = rk if os.path.isabs(rk) else os.path.join(_BASE_DIR, rk)
if "token_json_dir" in config:
td = config["token_json_dir"]
mod.TOKEN_JSON_DIR = td if os.path.isabs(td) else os.path.join(_BASE_DIR, td)
# ============================================================
# 批量注册
# ============================================================
def run_batch_register(
count: int,
workers: int,
proxy: str,
stop_event: threading.Event,
log_cb: Callable[[str], None],
progress_cb: Callable[[int, int, int], None],
config: Optional[dict] = None,
) -> dict:
"""
批量注册主函数(在线程中运行,通过回调输出日志)
Returns:
{"success": int, "fail": int, "total": int}
"""
mod = _get_cr()
_apply_config(mod, config or {})
output_file = (config or {}).get("output_file", "registered_accounts.txt")
if not os.path.isabs(output_file):
output_file = os.path.join(_BASE_DIR, output_file)
effective_proxy = proxy or (config or {}).get("proxy", "")
success_count = 0
fail_count = 0
total = count
_counter_lock = threading.Lock()
def register_one(idx: int):
nonlocal success_count, fail_count
if stop_event and stop_event.is_set():
return False, None, "已停止"
capture = _LogCapture(log_cb)
old_stdout = sys.stdout
sys.stdout = capture
try:
ok, email, err = mod._register_one(idx, total, effective_proxy, output_file)
except Exception as e:
ok, email, err = False, None, str(e)
finally:
sys.stdout = old_stdout
with _counter_lock:
if ok:
success_count += 1
else:
fail_count += 1
if progress_cb:
try:
progress_cb(success_count, fail_count, total)
except Exception:
pass
return ok, email, err
actual_workers = min(workers, count)
if log_cb:
log_cb(f"[注册] 开始批量注册: 数量={count}, 并发={actual_workers}, 代理={effective_proxy or '无'}")
with concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers) as executor:
futures = []
for i in range(1, count + 1):
if stop_event and stop_event.is_set():
break
futures.append(executor.submit(register_one, i))
for fut in concurrent.futures.as_completed(futures):
if stop_event and stop_event.is_set():
break
try:
fut.result()
except Exception as e:
if log_cb:
log_cb(f"[FAIL] 线程异常: {e}")
if log_cb:
log_cb(f"[注册] 完成: 成功={success_count}, 失败={fail_count}, 总计={total}")
return {"success": success_count, "fail": fail_count, "total": total}
# ============================================================
# 账号池管理(直接 HTTP 调用)
# ============================================================
def _pool_session(proxy: str = "", timeout: int = 10) -> _requests.Session:
s = _requests.Session()
if proxy:
p = proxy if "://" in proxy else f"http://{proxy}"
s.proxies = {"http": p, "https": p}
s.verify = False
return s
def _fetch_pool_files(
base_url: str,
token: str,
proxy: str = "",
timeout: int = 10,
) -> dict:
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
session = _pool_session(proxy, timeout)
try:
resp = session.get(url, headers=headers, timeout=timeout)
resp.raise_for_status()
raw = resp.json()
files = raw.get("files", []) if isinstance(raw, dict) else []
if not isinstance(files, list):
files = []
return {"ok": True, "files": files}
except Exception as e:
return {"ok": False, "error": str(e), "files": []}
def _list_local_token_names() -> tuple[set, set]:
tokens_dir = os.path.join(_BASE_DIR, "codex_tokens")
uploaded_dir = os.path.join(tokens_dir, "uploaded")
root_names: set = set()
if os.path.isdir(tokens_dir):
for f in os.listdir(tokens_dir):
if f.endswith(".json") and os.path.isfile(os.path.join(tokens_dir, f)):
root_names.add(f[:-5])
uploaded_names: set = set()
if os.path.isdir(uploaded_dir):
for f in os.listdir(uploaded_dir):
if f.endswith(".json") and os.path.isfile(os.path.join(uploaded_dir, f)):
uploaded_names.add(f[:-5])
return root_names, uploaded_names
def _build_sync_view(remote_accounts_raw: list, target_type: str) -> dict:
root_names, uploaded_names = _list_local_token_names()
def _strip_json(n: str) -> str:
return n[:-5] if n.endswith(".json") else n
remote_names: set = {_strip_json(a["name"]) for a in remote_accounts_raw if a.get("name")}
all_names = root_names | uploaded_names | remote_names
status_order = {"pending_move": 0, "pending_upload": 1, "remote_only": 2, "synced": 3, "local_only": 4}
accounts = []
summary = {"synced": 0, "pending_upload": 0, "pending_move": 0, "remote_only": 0, "local_only": 0}
for name in all_names:
in_root = name in root_names
in_uploaded = name in uploaded_names
in_remote = name in remote_names
if in_uploaded and in_remote:
status = "synced"
location = "uploaded"
elif in_root and not in_remote:
status = "pending_upload"
location = "root"
elif in_root and in_remote:
status = "pending_move"
location = "root"
elif in_remote and not in_uploaded and not in_root:
status = "remote_only"
location = "remote"
else:
status = "local_only"
location = "uploaded"
summary[status] = summary.get(status, 0) + 1
accounts.append({
"name": name,
"status": status,
"type": target_type,
"location": location,
})
accounts.sort(key=lambda x: status_order.get(x["status"], 99))
return {"accounts": accounts, "summary": summary}
def get_pool_status(
base_url: str,
token: str,
target_type: str = "codex",
proxy: str = "",
timeout: int = 10,
) -> dict:
"""获取账号池状态(总数、目标类型数)"""
fetched = _fetch_pool_files(base_url, token, proxy, timeout)
if not fetched.get("ok"):
return {"ok": False, "error": fetched.get("error", "获取失败"), "total": 0, "target": 0}
files = fetched.get("files", [])
total = len(files)
target_count = sum(
1 for f in files
if (f.get("type") or f.get("typo") or "").lower() == target_type.lower()
)
return {"ok": True, "total": total, "target": target_count, "target_type": target_type}
def get_pool_accounts(
base_url: str,
token: str,
target_type: str = "codex",
proxy: str = "",
timeout: int = 10,
) -> dict:
"""返回 CliProxyAPI 上指定类型的账号列表(名称)"""
fetched = _fetch_pool_files(base_url, token, proxy, timeout)
if not fetched.get("ok"):
return {"ok": False, "error": fetched.get("error", "获取失败"), "accounts": []}
files = fetched.get("files", [])
accounts = [f for f in files
if (f.get("type") or f.get("typo") or "").lower() == target_type.lower()]
return {"ok": True, "accounts": accounts, "total": len(accounts)}
def get_pool_overview(
base_url: str,
token: str,
target_type: str = "codex",
proxy: str = "",
timeout: int = 10,
) -> dict:
"""一次请求获取池状态与账号同步视图,避免重复拉取远端 files"""
fetched = _fetch_pool_files(base_url, token, proxy, timeout)
if not fetched.get("ok"):
return {"ok": False, "error": fetched.get("error", "获取远程账号失败")}
files = fetched.get("files", [])
accounts = [f for f in files
if (f.get("type") or f.get("typo") or "").lower() == target_type.lower()]
sync_view = _build_sync_view(accounts, target_type)
return {
"ok": True,
"total": len(files),
"target": len(accounts),
"target_type": target_type,
"accounts": sync_view.get("accounts", []),
"summary": sync_view.get("summary", {}),
}
def get_sync_status(
base_url: str,
pool_token: str,
target_type: str = "codex",
config: Optional[dict] = None,
proxy: str = "",
) -> dict:
"""只读:对比本地文件与远程账号,返回每个账号的同步状态"""
# 获取远程账号
remote_result = get_pool_accounts(base_url, pool_token, target_type, proxy)
if not remote_result.get("ok"):
return {"ok": False, "error": remote_result.get("error", "获取远程账号失败")}
remote_accounts_raw = remote_result.get("accounts", [])
sync_view = _build_sync_view(remote_accounts_raw, target_type)
return {"ok": True, "accounts": sync_view.get("accounts", []), "summary": sync_view.get("summary", {})}
def sync_local_remote(
base_url: str,
pool_token: str,
target_type: str = "codex",
config: Optional[dict] = None,
proxy: str = "",
log_cb: Optional[Callable[[str], None]] = None,
target_count: int = 0,
upload_only: bool = False,
) -> dict:
"""同步本地与远程:移动根目录中远程已有文件,按目标数补齐上传/下载账号数据
upload_only=True 时跳过 remote_only 下载,仅上传本地存量(补号场景使用)"""
def log(msg):
if log_cb:
log_cb(msg)
tokens_dir = os.path.join(_BASE_DIR, "codex_tokens")
uploaded_dir = os.path.join(tokens_dir, "uploaded")
os.makedirs(uploaded_dir, exist_ok=True)
sync_result = get_sync_status(base_url, pool_token, target_type, config, proxy)
if not sync_result.get("ok"):
return {"ok": False, "error": sync_result.get("error", "获取同步状态失败"), "moved": 0, "downloaded": 0, "uploaded": 0, "errors": []}
summary = sync_result.get("summary", {})
current_remote = summary.get("synced", 0) + summary.get("pending_move", 0) + summary.get("remote_only", 0)
gap = max(0, target_count - current_remote) if target_count > 0 else None
log(f"[同步] 远程当前 {current_remote} 个,目标 {target_count},待上传缺口 {gap if gap is not None else '无限制'}")
base = base_url.rstrip("/")
moved = 0
downloaded = 0
uploaded = 0
errors = []
counter_lock = threading.Lock()
upload_tasks = []
download_tasks = []
for acc in sync_result.get("accounts", []):
name = acc["name"]
status = acc["status"]
if status == "pending_move":
# 根目录已在远程 → 仅移到 uploaded/
src = os.path.join(tokens_dir, f"{name}.json")
dst = os.path.join(uploaded_dir, f"{name}.json")
try:
os.replace(src, dst)
log(f"[同步] 移动: {name}.json → uploaded/")
moved += 1
except Exception as e:
msg = f"[同步] 移动 {name} 失败: {e}"
log(msg)
errors.append(msg)
elif status in ("pending_upload", "local_only"):
# 本地有但远程无 → 上传(受目标数限制)
fpath = (os.path.join(tokens_dir, f"{name}.json") if status == "pending_upload"
else os.path.join(uploaded_dir, f"{name}.json"))
fname = f"{name}.json"
upload_tasks.append((name, status, fpath, fname))
elif status == "remote_only":
if upload_only:
continue
download_tasks.append(name)
if gap is not None and gap >= 0:
upload_tasks = upload_tasks[:gap]
def upload_one(task: tuple) -> None:
nonlocal uploaded
name, status, fpath, fname = task
session = _pool_session(proxy)
try:
with open(fpath, "rb") as f:
file_bytes = f.read()
r = session.post(
f"{base}/v0/management/auth-files",
files={"file": (fname, file_bytes, "application/json")},
headers={"Authorization": f"Bearer {pool_token}"},
timeout=10,
)
if r.status_code in (200, 201):
log(f"[同步] 上传成功: {name}")
with counter_lock:
uploaded += 1
if status == "pending_upload":
dst = os.path.join(uploaded_dir, fname)
os.replace(fpath, dst)
elif r.status_code == 409:
log(f"[同步] 已存在跳过: {name}")
if status == "pending_upload":
dst = os.path.join(uploaded_dir, fname)
os.replace(fpath, dst)
else:
try:
detail = r.json()
except Exception:
detail = r.text[:200]
msg = f"[同步] 上传失败: {name} ({r.status_code}) {detail}"
log(msg)
with counter_lock:
errors.append(msg)
except Exception as e:
msg = f"[同步] 上传异常: {name} - {e}"
log(msg)
with counter_lock:
errors.append(msg)
def download_one(name: str) -> None:
nonlocal downloaded
session = _pool_session(proxy)
try:
resp = session.get(
f"{base}/v0/management/auth-files/download",
params={"name": f"{name}.json"},
headers={"Authorization": f"Bearer {pool_token}", "Accept": "application/json"},
timeout=15,
)
resp.raise_for_status()
acc_data = resp.json()
dst = os.path.join(uploaded_dir, f"{name}.json")
with open(dst, "w", encoding="utf-8") as f:
json.dump(acc_data, f, ensure_ascii=False, indent=2)
log(f"[同步] 下载: {name} → uploaded/{name}.json")
with counter_lock:
downloaded += 1
except Exception as e:
msg = f"[同步] 下载 {name} 失败: {e}"
log(msg)
with counter_lock:
errors.append(msg)
if upload_tasks:
with concurrent.futures.ThreadPoolExecutor(max_workers=POOL_SYNC_UPLOAD_WORKERS) as ex:
list(ex.map(upload_one, upload_tasks))
if download_tasks:
with concurrent.futures.ThreadPoolExecutor(max_workers=POOL_SYNC_DOWNLOAD_WORKERS) as ex:
list(ex.map(download_one, download_tasks))
return {"ok": True, "moved": moved, "downloaded": downloaded, "uploaded": uploaded, "errors": errors}
def run_pool_probe(
base_url: str,
token: str,
target_type: str = "codex",
proxy: str = "",
timeout: int = 10,
log_cb: Optional[Callable[[str], None]] = None,
) -> dict:
"""探测账号池,找出 401 失效账号"""
def log(msg):
if log_cb:
log_cb(msg)
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
session = _pool_session(proxy, timeout)
log("[Pool] 获取账号列表...")
try:
resp = session.get(url, headers=headers, timeout=timeout)
resp.raise_for_status()
raw = resp.json()
files = raw.get("files", []) if isinstance(raw, dict) else []
except Exception as e:
log(f"[Pool] 获取失败: {e}")
return {"ok": False, "error": str(e), "invalid_401": [], "total": 0, "target": 0}
total = len(files)
target_files = [f for f in files if (f.get("type") or f.get("typo") or "").lower() == target_type.lower()]
log(f"[Pool] 总账号: {total}, {target_type} 账号: {len(target_files)}")
invalid_401 = []
probe_lock = threading.Lock()
checked = 0
checked_lock = threading.Lock()
def extract_chatgpt_account_id(item: dict) -> str:
id_token = item.get("id_token")
if not isinstance(id_token, dict):
return ""
v = id_token.get("chatgpt_account_id")
return str(v) if v else ""
def build_probe_payload(auth_index: str, chatgpt_account_id: str) -> dict:
call_header = {
"Authorization": "Bearer $TOKEN$",
"Content-Type": "application/json",
"User-Agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
}
if chatgpt_account_id:
call_header["Chatgpt-Account-Id"] = chatgpt_account_id
return {
"authIndex": auth_index,
"method": "GET",
"url": "https://chatgpt.com/backend-api/wham/usage",
"header": call_header,
}
def probe_one(f):
nonlocal checked
name = f.get("name") or f.get("id", "")
auth_index = f.get("auth_index")
if not auth_index:
log(f"[Pool] 跳过(缺少 auth_index): {name}")
with checked_lock:
checked += 1
if checked == 1 or checked % 20 == 0 or checked == len(target_files):
log(f"[Pool] 探测进度: {checked}/{len(target_files)}, 401={len(invalid_401)}")
return
try:
payload = build_probe_payload(str(auth_index), extract_chatgpt_account_id(f))
r = session.post(
f"{base_url.rstrip('/')}/v0/management/api-call",
headers={**headers, "Content-Type": "application/json"},
json=payload,
timeout=timeout,
)
r.raise_for_status()
data = r.json() if r.content else {}
status_code = data.get("status_code") if isinstance(data, dict) else None
if status_code == 401:
with probe_lock:
invalid_401.append({"name": name, "status": 401})
log(f"[Pool] 401: {name}")
elif status_code is None:
log(f"[Pool] 探测返回缺少 status_code: {name}")
except Exception as e:
log(f"[Pool] 探测异常: {name} - {e}")
finally:
with checked_lock:
checked += 1
if checked == 1 or checked % 20 == 0 or checked == len(target_files):
log(f"[Pool] 探测进度: {checked}/{len(target_files)}, 401={len(invalid_401)}")
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(probe_one, target_files))
log(f"[Pool] 探测完成: 401 失效 {len(invalid_401)} 个")
return {
"ok": True,
"total": total,
"target": len(target_files),
"invalid_401": invalid_401,
"invalid_count": len(invalid_401),
}
def run_pool_clean(
base_url: str,
token: str,
target_type: str = "codex",
proxy: str = "",
timeout: int = 10,
log_cb: Optional[Callable[[str], None]] = None,
) -> dict:
"""清理 401 失效账号(探测 + 删除)"""
def log(msg):
if log_cb:
log_cb(msg)
probe_result = run_pool_probe(base_url, token, target_type, proxy, timeout, log_cb)
if not probe_result.get("ok"):
return probe_result
return run_pool_clean_with_probe_result(
base_url=base_url,
token=token,
probe_result=probe_result,
proxy=proxy,
timeout=timeout,
log_cb=log_cb,
)
def _delete_invalid_accounts(
base_url: str,
token: str,
invalid_401: list,
proxy: str = "",
timeout: int = 10,
log_cb: Optional[Callable[[str], None]] = None,
) -> dict:
"""根据 invalid_401 列表执行删除,返回删除统计"""
def log(msg):
if log_cb:
log_cb(msg)
if not invalid_401:
log("[Pool] 无需清理,没有 401 账号")
return {"deleted": 0, "delete_fail": 0}
log(f"[Pool] 开始删除 {len(invalid_401)} 个失效账号...")
base = base_url.rstrip("/")
headers = {"Authorization": f"Bearer {token}"}
session = _pool_session(proxy, timeout)
deleted = 0
delete_fail = 0
del_lock = threading.Lock()
def delete_one(item):
nonlocal deleted, delete_fail
name = item.get("name", "")
try:
encoded_name = quote(name, safe="")
r = session.delete(
f"{base}/v0/management/auth-files?name={encoded_name}",
headers=headers,
timeout=timeout,
)
with del_lock:
ok = False
if r.status_code == 204:
ok = True
elif r.status_code == 200:
try:
data = r.json() if r.content else {}
except Exception:
data = {}
ok = isinstance(data, dict) and data.get("status") == "ok"
if ok:
deleted += 1
log(f"[Pool] 删除成功: {name}")
# 同步删除本地副本(根目录和 uploaded/)
clean_name = name[:-5] if name.endswith(".json") else name
for local_path in [
os.path.join(_BASE_DIR, "codex_tokens", f"{clean_name}.json"),
os.path.join(_BASE_DIR, "codex_tokens", "uploaded", f"{clean_name}.json"),
]:
if os.path.isfile(local_path):
try:
os.remove(local_path)
log(f"[Pool] 本地删除: {clean_name}.json")
except Exception as ex:
log(f"[Pool] 本地删除失败: {clean_name} - {ex}")
else:
delete_fail += 1
detail = ""
try:
detail = r.text[:200]
except Exception:
pass
log(f"[Pool] 删除失败: {name} ({r.status_code}) {detail}")
except Exception as e:
with del_lock:
delete_fail += 1
log(f"[Pool] 删除异常: {name} - {e}")
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
list(ex.map(delete_one, invalid_401))
log(f"[Pool] 清理完成: 删除成功={deleted}, 失败={delete_fail}")
return {"deleted": deleted, "delete_fail": delete_fail}
def run_pool_clean_with_probe_result(
base_url: str,
token: str,
probe_result: Dict[str, Any],
proxy: str = "",
timeout: int = 10,
log_cb: Optional[Callable[[str], None]] = None,
) -> dict:
"""使用现成探测结果清理 401 账号(不重复探测)"""
if not isinstance(probe_result, dict):
return {"ok": False, "error": "invalid probe_result", "invalid_401": [], "total": 0, "target": 0}
invalid_401 = probe_result.get("invalid_401", [])
if not isinstance(invalid_401, list):
return {"ok": False, "error": "invalid probe_result.invalid_401", "invalid_401": [], "total": 0, "target": 0}
delete_result = _delete_invalid_accounts(
base_url=base_url,
token=token,
invalid_401=invalid_401,
proxy=proxy,
timeout=timeout,
log_cb=log_cb,
)
return {**probe_result, **delete_result, "ok": True}
def _upload_tokens_to_pool(
base_url: str,
pool_token: str,
config: Optional[dict] = None,
proxy: str = "",
log_cb: Optional[Callable[[str], None]] = None,
) -> int:
"""将 token_json_dir 下的 JSON 文件上传到账号池,返回上传成功数"""
def log(msg):
if log_cb:
log_cb(msg)
token_dir = (config or {}).get("token_json_dir", "codex_tokens")
if not os.path.isabs(token_dir):
token_dir = os.path.join(_BASE_DIR, token_dir)
if not os.path.isdir(token_dir):
log(f"[Pool] token 目录不存在: {token_dir}")
return 0
uploaded = 0
uploaded_lock = threading.Lock()
base = base_url.rstrip("/")
upload_headers = {"Authorization": f"Bearer {pool_token}"}
uploaded_dir = os.path.join(token_dir, "uploaded")
os.makedirs(uploaded_dir, exist_ok=True)
tasks = []
for fname in os.listdir(token_dir):
if fname.endswith(".json"):
tasks.append((fname, os.path.join(token_dir, fname)))
def upload_one(task: tuple) -> None:
nonlocal uploaded
fname, fpath = task
session = _pool_session(proxy, 10)
try:
with open(fpath, "rb") as f:
file_bytes = f.read()
r = session.post(
f"{base}/v0/management/auth-files",
files={"file": (fname, file_bytes, "application/json")},
headers=upload_headers,
timeout=10,
)
if r.status_code in (200, 201):
with uploaded_lock:
uploaded += 1
log(f"[Pool] 上传成功: {fname}")
os.replace(fpath, os.path.join(uploaded_dir, fname))
elif r.status_code == 409:
log(f"[Pool] 已存在跳过: {fname}")
os.replace(fpath, os.path.join(uploaded_dir, fname))
else:
log(f"[Pool] 上传失败: {fname} ({r.status_code})")
except Exception as e:
log(f"[Pool] 上传异常: {fname} - {e}")
if tasks:
with concurrent.futures.ThreadPoolExecutor(max_workers=POOL_TOKEN_UPLOAD_WORKERS) as ex:
list(ex.map(upload_one, tasks))
log(f"[Pool] 上传完成: {uploaded} 个 token")
return uploaded
def run_pool_fill(
fill_count: int,
base_url: str,
pool_token: str,
stop_event: threading.Event,
log_cb: Callable[[str], None],
progress_cb: Callable[[int, int, int], None],
config: Optional[dict] = None,
proxy: str = "",
target_count: int = 0,
target_type: str = "codex",
) -> dict:
"""补号:注册新账号并尝试上传到账号池"""
def log(msg):
if log_cb:
log_cb(msg)
log(f"[Pool] 开始补号: 目标数量={fill_count}")
# 先同步本地存量到远程,减少实际需要注册的数量(跳过 remote_only 下载)
pre_uploaded = 0
if base_url and pool_token:
log("[Pool] 先同步本地存量到远程...")
sync_r = sync_local_remote(base_url, pool_token, target_type, config, proxy, log_cb,
target_count=target_count, upload_only=True)
pre_uploaded = sync_r.get("uploaded", 0)
if pre_uploaded > 0:
fill_count = max(0, fill_count - pre_uploaded)
log(f"[Pool] 存量上传 {pre_uploaded} 个,剩余需注册 {fill_count} 个")
if fill_count == 0:
return {"success": 0, "fail": 0, "total": 0, "uploaded": pre_uploaded}
cfg_workers = (config or {}).get("workers", 3)
result = run_batch_register(
count=fill_count,
workers=min(cfg_workers, fill_count),
proxy=proxy or (config or {}).get("proxy", ""),
stop_event=stop_event,
log_cb=log_cb,
progress_cb=progress_cb,
config=config,
)
registered = result.get("success", 0)
if registered > 0 and base_url and pool_token:
log("[Pool] 尝试上传新 token 到账号池...")
uploaded = _upload_tokens_to_pool(base_url, pool_token, config, proxy, log_cb)
result["uploaded"] = uploaded + pre_uploaded
else:
result["uploaded"] = pre_uploaded
return result
# ============================================================
# 免费代理工具
# ============================================================
_PROXY_SOURCES = [
"https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt",
"https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt",
"https://raw.githubusercontent.com/ShiftyTR/Proxy-List/master/http.txt",
"https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt",
]
def fetch_free_proxies(timeout: int = 10, proxy: str = "") -> List[str]:
"""从公开代理源获取免费代理列表,proxy 用于访问 GitHub(国内环境需要)"""
session = _requests.Session()
session.verify = False
if proxy:
p = proxy if "://" in proxy else f"http://{proxy}"
session.proxies = {"http": p, "https": p}
proxies = set()
for url in _PROXY_SOURCES:
try:
resp = session.get(url, timeout=timeout)
if resp.status_code == 200:
for line in resp.text.splitlines():
line = line.strip()
if line and not line.startswith("#"):
if ":" in line and not line.startswith("http"):
proxies.add(f"http://{line}")
elif line.startswith("http"):
proxies.add(line)
except Exception:
pass
return sorted(proxies)[:200] # 最多返回 200 个
def test_proxy(
proxy: str,
target_url: str = "https://httpbin.org/ip",
timeout: int = 5,
) -> dict:
"""测试代理可用性,返回延迟和状态"""
if not proxy:
return {"proxy": proxy, "ok": False, "error": "空代理"}
proxy_url = proxy if "://" in proxy else f"http://{proxy}"
proxies = {"http": proxy_url, "https": proxy_url}
start = time.time()
try:
resp = _requests.get(target_url, proxies=proxies, timeout=timeout, verify=False)
elapsed = int((time.time() - start) * 1000)
if resp.status_code == 200:
return {"proxy": proxy, "ok": True, "latency_ms": elapsed, "status": resp.status_code}
return {"proxy": proxy, "ok": False, "latency_ms": elapsed, "status": resp.status_code}
except _requests.exceptions.Timeout:
return {"proxy": proxy, "ok": False, "error": "超时", "latency_ms": timeout * 1000}
except Exception as e:
return {"proxy": proxy, "ok": False, "error": str(e)[:80]}
def test_proxies_concurrent(
proxy_list: List[str],
target_url: str = "https://httpbin.org/ip",
timeout: int = 5,
max_workers: int = 20,
) -> List[dict]:
"""并发测试多个代理"""
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex:
results = list(ex.map(lambda p: test_proxy(p, target_url, timeout), proxy_list))
return results
# ============================================================
# 配置文件读写
# ============================================================
def _resolve_config_path() -> str:
"""优先使用项目根 config.json(兼容 Docker /app/config.json 挂载);否则回退 data/config.json"""
root_cfg = os.path.join(_BASE_DIR, "config.json")
data_cfg = os.path.join(_BASE_DIR, "data", "config.json")
if os.path.exists(root_cfg):
return root_cfg
if os.path.exists(data_cfg):
return data_cfg
if os.path.isdir(os.path.join(_BASE_DIR, "data")):
return data_cfg
return root_cfg
def load_config() -> dict:
"""读取 config.json"""
cfg_path = _resolve_config_path()
defaults = {
"total_accounts": 3,
"duckmail_api_base": "https://api.duckmail.sbs",
"duckmail_bearer": "",
"proxy": "",
"output_file": "registered_accounts.txt",
"enable_oauth": True,
"oauth_required": True,
"oauth_issuer": "https://auth.openai.com",
"oauth_client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
"oauth_redirect_uri": "http://localhost:1455/auth/callback",
"ak_file": "ak.txt",
"rk_file": "rk.txt",
"token_json_dir": "codex_tokens",
"auth": {
"enabled": True,
"username": "admin",
"password_hash": "",
"jwt_secret": "",
"jwt_exp_hours": 24,
"cookie_name": "cc_auth",
},
"pool": {
"base_url": "",
"token": "",
"target_type": "codex",
"min_candidates": 100,
},
}
if os.path.exists(cfg_path):
try:
with open(cfg_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
defaults.update(file_cfg)
if isinstance(defaults.get("pool"), dict) and isinstance(file_cfg.get("pool"), dict):
defaults["pool"] = {**defaults["pool"], **file_cfg["pool"]}
if isinstance(defaults.get("auth"), dict) and isinstance(file_cfg.get("auth"), dict):
defaults["auth"] = {**defaults["auth"], **file_cfg["auth"]}
except Exception:
pass
return defaults
def save_config(config: dict) -> bool:
"""保存 config.json"""
cfg_path = _resolve_config_path()
try:
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
# ============================================================
# 结果文件读取
# ============================================================
def read_registered_accounts(config: Optional[dict] = None) -> List[dict]:
"""解析 registered_accounts.txt"""
output_file = (config or {}).get("output_file", "registered_accounts.txt")
if not os.path.isabs(output_file):
output_file = os.path.join(_BASE_DIR, output_file)
accounts = []
if not os.path.exists(output_file):
return accounts
try:
with open(output_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split("----")
if len(parts) >= 2:
accounts.append({
"email": parts[0],
"password": parts[1],
"email_password": parts[2] if len(parts) > 2 else "",
"oauth": parts[3] if len(parts) > 3 else "",
})
except Exception:
pass
return accounts
def read_token_file(filename: str, config: Optional[dict] = None) -> str:
"""读取 ak.txt 或 rk.txt 内容"""
key = "ak_file" if "ak" in filename.lower() else "rk_file"
path = (config or {}).get(key, filename)
if not os.path.isabs(path):
path = os.path.join(_BASE_DIR, path)
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def list_codex_tokens(config: Optional[dict] = None) -> List[dict]:
"""列出 codex_tokens/ 目录下的所有 JSON token"""
token_dir = (config or {}).get("token_json_dir", "codex_tokens")
if not os.path.isabs(token_dir):
token_dir = os.path.join(_BASE_DIR, token_dir)
tokens = []
if not os.path.isdir(token_dir):
return tokens
for fname in os.listdir(token_dir):
if not fname.endswith(".json"):
continue
fpath = os.path.join(token_dir, fname)
try:
with open(fpath, "r", encoding="utf-8") as f:
data = json.load(f)
tokens.append(data)
except Exception:
pass
tokens.sort(key=lambda x: x.get("last_refresh", ""), reverse=True)
return tokens
# ============================================================
# 代理池单例
# ============================================================
class _ProxyPool:
"""免费代理池单例,存储最近测试结果,提供最优代理"""
def __init__(self):
self._proxies: List[dict] = [] # [{proxy, ok, latency_ms}, ...]
self._lock = threading.Lock()
def update(self, results: List[dict]):
"""更新测试结果,按延迟排序(可用的排前面)"""
with self._lock:
working = sorted(
[r for r in results if r.get("ok")],
key=lambda x: x.get("latency_ms", 99999),
)
failed = [r for r in results if not r.get("ok")]
self._proxies = working + failed
def get_best(self, fallback: str = "") -> str:
"""返回延迟最低的可用免费代理,若无则返回 fallback"""
with self._lock:
working = [p for p in self._proxies if p.get("ok")]
if working:
return working[0]["proxy"]
return fallback
def get_all(self) -> List[dict]:
"""返回所有代理的副本"""
with self._lock:
return list(self._proxies)
_proxy_pool = _ProxyPool()
# ============================================================
# 号池自动维护周期
# ============================================================
def run_pool_maintain_cycle(
base_url: str,
token: str,
target_type: str,
target_count: int,
stop_event: threading.Event,
log_cb: Callable[[str], None],
config: Optional[dict] = None,
proxy: str = "",
) -> dict:
"""
号池维护一次完整周期:
1. 获取当前池状态
2. 清理 401 失效账号
3. 计算缺口,若缺口 > 0 则注册新账号并上传到池
4. 返回统计结果
"""
def log(msg):
if log_cb:
log_cb(msg)
log(f"[Daemon] 开始维护周期: 目标类型={target_type}, 目标数量={target_count}")
# 1. 获取当前状态
status = get_pool_status(base_url, token, target_type, proxy)
if not status.get("ok"):
log(f"[Daemon] 获取池状态失败: {status.get('error')}")
return {"ok": False, "error": status.get("error")}
log(f"[Daemon] 当前 {target_type} 账号数: {status['target']}")
# 2. 清理 401 账号
clean_result = run_pool_clean(base_url, token, target_type, proxy, log_cb=log_cb)
if not clean_result.get("ok"):
log(f"[Daemon] 清理失败,跳过补号: {clean_result.get('error')}")
return {"ok": False, "error": clean_result.get("error")}
deleted = clean_result.get("deleted", 0)
log(f"[Daemon] 清理完成: 删除 {deleted} 个失效账号")
# 3. 重新获取有效数量
status_after = get_pool_status(base_url, token, target_type, proxy)
valid_count = status_after.get("target", 0) if status_after.get("ok") else (status["target"] - deleted)
gap = target_count - valid_count
log(f"[Daemon] 清理后有效账号: {valid_count}, 目标: {target_count}, 缺口: {gap}")
# 4. 若有缺口则先同步本地存量,再注册补充
registered = 0
uploaded = 0
if gap > 0:
if stop_event and stop_event.is_set():
log("[Daemon] 任务已停止,跳过补号")
else:
log("[Daemon] 先同步本地存量到远程...")
sync_r = sync_local_remote(base_url, token, target_type, config, proxy, log_cb, target_count)
pre_uploaded = sync_r.get("uploaded", 0)
if pre_uploaded > 0:
status_synced = get_pool_status(base_url, token, target_type, proxy)
valid_count = status_synced.get("target", valid_count) if status_synced.get("ok") else valid_count + pre_uploaded
gap = max(0, target_count - valid_count)
log(f"[Daemon] 存量上传 {pre_uploaded} 个,同步后有效账号: {valid_count},剩余缺口: {gap}")
if gap > 0:
log(f"[Daemon] 开始注册 {gap} 个账号...")
cfg_workers = (config or {}).get("workers", 3)
reg_result = run_batch_register(
count=gap,
workers=min(cfg_workers, gap),
proxy=proxy,
stop_event=stop_event,
log_cb=log_cb,
progress_cb=lambda s, f, t: None,
config=config,
)
registered = reg_result.get("success", 0)
log(f"[Daemon] 注册完成: 成功={registered}, 失败={reg_result.get('fail', 0)}")
if registered > 0 and base_url and token:
log("[Daemon] 上传新 token 到账号池...")
uploaded = _upload_tokens_to_pool(base_url, token, config, proxy, log_cb)
else:
log("[Daemon] 存量补齐,无需注册新账号")
else:
log("[Daemon] 账号数量充足,无需补号")
log(f"[Daemon] 维护周期完成: 删除={deleted}, 注册={registered}, 上传={uploaded}")
return {
"ok": True,
"valid_before": status["target"],
"valid_after": valid_count,
"deleted": deleted,
"registered": registered,
"uploaded": uploaded,
"gap": gap,
}