Switch CPA export to SSO→Authorization Code with referrer=grok-build, keep device-code as optional fallback, and capture new register/auth artifacts.
2961 lines
113 KiB
Python
2961 lines
113 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Grok 注册机 - TTK GUI 版本
|
||
整合 DrissionPage_example.py, openai_register.py, batch_open_nsfw.py
|
||
"""
|
||
|
||
import threading
|
||
import datetime
|
||
import time
|
||
import os
|
||
import sys
|
||
import gc
|
||
import secrets
|
||
import struct
|
||
import random
|
||
import re
|
||
import string
|
||
import json
|
||
|
||
from DrissionPage import Chromium, ChromiumOptions
|
||
from DrissionPage.errors import PageDisconnectedError
|
||
from curl_cffi import requests
|
||
import psutil
|
||
|
||
|
||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||
MEMORY_CLEANUP_INTERVAL = 5
|
||
|
||
DEFAULT_CONFIG = {
|
||
"duckmail_api_key": "",
|
||
"cloudflare_api_base": "",
|
||
"cloudflare_api_key": "",
|
||
"cloudflare_auth_mode": "none",
|
||
"cloudflare_path_domains": "/api/domains",
|
||
"cloudflare_path_accounts": "/api/new_address",
|
||
"cloudflare_path_token": "/api/token",
|
||
"cloudflare_path_messages": "/api/mails",
|
||
"proxy": "http://127.0.0.1:7890",
|
||
"enable_nsfw": True,
|
||
"register_count": 1,
|
||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||
# ===== CPA (CLIProxyAPI) 导出 / 免费 Grok 4.5(OIDC)=====
|
||
# 注册成功后用 SSO cookie 走 Authorization Code + PKCE,强制 referrer=grok-build,
|
||
# 写出 CLIProxyAPI 的 xai-<email>.json,并可推送到远端。免费号用 cli-chat-proxy。
|
||
# 旧设备码铸造的 token 缺 referrer,cli-chat-proxy 已不可用。
|
||
"cpa_export_enabled": True, # 注册成功后是否铸造 OIDC 并写 CPA 认证文件
|
||
"cpa_auth_dir": "./cpa_auths", # 本地写盘目录(xai-<email>.json)
|
||
"cpa_base_url": "https://cli-chat-proxy.grok.com/v1", # 写进 auth 的 base_url;付费用 https://api.x.ai/v1
|
||
# 远端推送到 CLIProxyAPI(POST /v0/management/auth-files)
|
||
"cpa_push_enabled": False, # 是否推送到远端 CLIProxyAPI
|
||
"cpa_remote_base": "", # 远端 CLIProxyAPI 地址,如 http://127.0.0.1:8317
|
||
"cpa_remote_secret": "", # 远端管理密钥(CLIProxyAPI remote-management.secret-key)
|
||
"cpa_remote_verify_tls": True, # https 远端是否校验证书
|
||
"cpa_push_proxy": "", # 推送用代理;空=直连(不走 mint 代理)
|
||
"cpa_push_required": False, # True=推送失败则整次注册算失败
|
||
# OIDC:优先 SSO→Authorization Code + referrer=grok-build
|
||
"cpa_prefer_sso_oauth": True, # True=用 sso cookie 走 PKCE(必须带 referrer)
|
||
"cpa_require_referrer": True, # True=access_token 无 referrer=grok-build 则失败
|
||
"cpa_allow_device_fallback": False, # True=SSO 失败时回退设备码(通常不可用)
|
||
# OIDC 铸造代理/超时
|
||
"mint_proxy": "", # 铸造专用代理;空=复用 proxy
|
||
"mint_timeout_sec": 300, # 单账号铸造超时(秒)
|
||
"mint_required": False, # True=铸造失败则整次注册算失败
|
||
# 以下优化默认关闭 = 与最初脚本一致;实测三者都不影响 Turnstile 通过,可按需开启。
|
||
# (注:Blink 层禁图会导致验证过不去,已弃用;省图片带宽改由 block_media_fonts 承担)
|
||
"block_media_fonts": False, # 网络层拦截图片/字体/媒体省带宽(省带宽主力,不影响验证)
|
||
"hide_window": False, # 把浏览器窗口移到屏幕外隐藏
|
||
"stealth_patch": False, # 内联 turnstilePatch 全局注入
|
||
# ===== CF Email Routing Worker 取码(非 temp-email)=====
|
||
# 域名在 Email Routing catch-all → Worker KV → GET /code
|
||
"cf_worker_domains": "", # 走 Worker 的域名,逗号分隔,如 zhangyunuo.net
|
||
"gmail_worker_enabled": False,
|
||
"gmail_worker_url": "",
|
||
"gmail_worker_api_key": "",
|
||
"gmail_worker_timeout_sec": 20,
|
||
"gmail_worker_poll_interval": 0.4,
|
||
"gmail_imap_fallback": False, # Worker 未命中是否回退 Gmail IMAP
|
||
"gmail_imap_user": "",
|
||
"gmail_imap_password": "",
|
||
"gmail_imap_host": "imap.gmail.com",
|
||
"gmail_imap_port": 993,
|
||
}
|
||
|
||
config = DEFAULT_CONFIG.copy()
|
||
_cf_domain_index = 0
|
||
_cf_domain_lock = threading.Lock()
|
||
|
||
|
||
class RegistrationCancelled(Exception):
|
||
pass
|
||
|
||
|
||
class AccountRetryNeeded(Exception):
|
||
pass
|
||
|
||
|
||
def load_config():
|
||
global config
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||
loaded = json.load(f)
|
||
config = {**DEFAULT_CONFIG, **loaded}
|
||
except Exception:
|
||
config = DEFAULT_CONFIG.copy()
|
||
return config
|
||
|
||
|
||
def save_config():
|
||
try:
|
||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(config, f, indent=4, ensure_ascii=False)
|
||
except Exception as e:
|
||
print(f"保存配置失败: {e}")
|
||
|
||
|
||
def ensure_stable_python_runtime():
|
||
if sys.version_info < (3, 14) or os.environ.get("DPE_REEXEC_DONE") == "1":
|
||
return
|
||
|
||
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
||
candidates = [
|
||
os.path.join(local_app_data, "Programs", "Python", "Python312", "python.exe"),
|
||
os.path.join(local_app_data, "Programs", "Python", "Python313", "python.exe"),
|
||
]
|
||
|
||
current_python = os.path.normcase(os.path.abspath(sys.executable))
|
||
for candidate in candidates:
|
||
if not os.path.isfile(candidate):
|
||
continue
|
||
if os.path.normcase(os.path.abspath(candidate)) == current_python:
|
||
return
|
||
|
||
print(
|
||
f"[*] 检测到 Python {sys.version.split()[0]},自动切换到更稳定的解释器: {candidate}"
|
||
)
|
||
env = os.environ.copy()
|
||
env["DPE_REEXEC_DONE"] = "1"
|
||
os.execve(candidate, [candidate, os.path.abspath(__file__), *sys.argv[1:]], env)
|
||
|
||
|
||
def warn_runtime_compatibility():
|
||
if sys.version_info >= (3, 14):
|
||
print(
|
||
"[提示] 当前 Python 为 3.14+;若出现 Mail.tm TLS 异常,建议改用 Python 3.12 或 3.13。"
|
||
)
|
||
|
||
|
||
ensure_stable_python_runtime()
|
||
warn_runtime_compatibility()
|
||
|
||
load_config()
|
||
|
||
EXTENSION_PATH = os.path.abspath(
|
||
os.path.join(os.path.dirname(__file__), "turnstilePatch")
|
||
)
|
||
|
||
|
||
DUCKMAIL_API_BASE = "https://api.duckmail.sbs"
|
||
|
||
|
||
def get_proxies():
|
||
proxy = config.get("proxy", "")
|
||
if proxy:
|
||
return {"http": proxy, "https": proxy}
|
||
return {}
|
||
|
||
|
||
def get_duckmail_api_key():
|
||
return config.get("duckmail_api_key", "")
|
||
|
||
|
||
def get_cloudflare_api_base():
|
||
return str(config.get("cloudflare_api_base", "") or "").rstrip("/")
|
||
|
||
|
||
def get_cloudflare_api_key():
|
||
return config.get("cloudflare_api_key", "")
|
||
|
||
|
||
def get_cloudflare_auth_mode():
|
||
return str(config.get("cloudflare_auth_mode", "none") or "none").lower()
|
||
|
||
|
||
def get_cloudflare_path(key, default_path):
|
||
raw = str(config.get(key, default_path) or default_path).strip()
|
||
if not raw.startswith("/"):
|
||
raw = "/" + raw
|
||
return raw
|
||
|
||
|
||
def cloudflare_build_headers(content_type=False):
|
||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key:
|
||
if mode == "x-api-key":
|
||
headers["X-API-Key"] = key
|
||
elif mode == "x-admin-auth":
|
||
headers["x-admin-auth"] = key
|
||
elif mode != "none":
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
return headers
|
||
|
||
|
||
def cloudflare_apply_auth_params(params=None):
|
||
merged = dict(params or {})
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key and mode == "query-key":
|
||
merged["key"] = key
|
||
return merged
|
||
|
||
|
||
def cloudflare_default_domains():
|
||
"""解析 config.defaultDomains(逗号分隔)为域名列表。"""
|
||
return [x.strip() for x in str(config.get("defaultDomains", "") or "").split(",") if x.strip()]
|
||
|
||
|
||
def cloudflare_next_default_domain():
|
||
"""按配置轮换选择 Cloudflare 临时邮箱域名。"""
|
||
global _cf_domain_index
|
||
domains = cloudflare_default_domains()
|
||
if not domains:
|
||
return ""
|
||
with _cf_domain_lock:
|
||
domain = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
return domain
|
||
|
||
|
||
def cloudflare_is_admin_create_path(path):
|
||
"""判断当前创建邮箱路径是否为 cloudflare_temp_email 管理员创建接口。"""
|
||
return str(path or "").rstrip("/").lower() == "/admin/new_address"
|
||
|
||
|
||
def _pick_list_payload(data):
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict):
|
||
if isinstance(data.get("results"), list):
|
||
return data.get("results")
|
||
if isinstance(data.get("hydra:member"), list):
|
||
return data.get("hydra:member")
|
||
if isinstance(data.get("data"), list):
|
||
return data.get("data")
|
||
if isinstance(data.get("messages"), list):
|
||
return data.get("messages")
|
||
if isinstance(data.get("data"), dict):
|
||
nested = data.get("data")
|
||
if isinstance(nested.get("messages"), list):
|
||
return nested.get("messages")
|
||
return []
|
||
|
||
|
||
def cloudflare_create_temp_address(api_base, preferred_domain=None):
|
||
"""适配 cloudflare_temp_email 新建地址接口并兼容 admin 创建模式。
|
||
|
||
多域名时按 defaultDomains 顺序尝试;某个域名 Invalid domain 时自动换下一个。
|
||
preferred_domain: 强制优先该域名(用于双通道轮换,避免改全局 config)。
|
||
会自动跳过 cf_worker_domains(那些域名不走 temp-email)。
|
||
"""
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/api/new_address")
|
||
url = f"{api_base}{path}"
|
||
worker_skip = cf_worker_domain_set()
|
||
domains = cloudflare_default_domains()
|
||
domains = [d for d in domains if d and d.lower() not in worker_skip]
|
||
if preferred_domain:
|
||
pref = preferred_domain.strip()
|
||
if pref and pref.lower() not in worker_skip:
|
||
domains = [pref] + [d for d in domains if d.lower() != pref.lower()]
|
||
if not domains:
|
||
domains = [""]
|
||
is_admin_create = cloudflare_is_admin_create_path(path)
|
||
last_err = None
|
||
if preferred_domain:
|
||
# 外层已轮换选域,这里不再推进全局下标
|
||
ordered = domains
|
||
else:
|
||
# 从轮换下标开始,保证多域名时负载分散,且失败可回退
|
||
start = 0
|
||
with _cf_domain_lock:
|
||
start = _cf_domain_index % max(len(domains), 1)
|
||
_cf_domain_index += 1
|
||
ordered = domains[start:] + domains[:start]
|
||
for domain in ordered:
|
||
if is_admin_create:
|
||
payload = {"name": generate_username(10), "enablePrefix": True}
|
||
if domain:
|
||
payload["domain"] = domain
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
else:
|
||
payload = {}
|
||
if domain:
|
||
payload["domain"] = domain
|
||
headers = {"Content-Type": "application/json"}
|
||
try:
|
||
resp = http_post(url, json=payload, headers=headers)
|
||
body_text = resp.text or ""
|
||
if resp.status_code >= 400:
|
||
last_err = f"HTTP {resp.status_code}: {body_text[:200]}"
|
||
# 域名未绑定到 temp-email 时常见错误,换下一个域名
|
||
low = body_text.lower()
|
||
if "invalid domain" in low or "domain" in low and "invalid" in low:
|
||
continue
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare {path} 返回非JSON: {body_text[:300]}")
|
||
address = data.get("address")
|
||
jwt = data.get("jwt")
|
||
if not address or not jwt:
|
||
last_err = f"缺少 address/jwt: {data}"
|
||
continue
|
||
return address, jwt
|
||
except Exception as exc:
|
||
last_err = str(exc)
|
||
low = last_err.lower()
|
||
if "invalid domain" in low:
|
||
continue
|
||
# 非域名类错误不继续盲试
|
||
if domain == ordered[-1]:
|
||
break
|
||
if "domain" in low:
|
||
continue
|
||
raise
|
||
raise Exception(
|
||
f"Cloudflare 创建邮箱失败(已尝试域名 {', '.join([d or '(默认)' for d in ordered])}): {last_err}"
|
||
)
|
||
|
||
|
||
def get_user_agent():
|
||
return config.get(
|
||
"user_agent",
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||
)
|
||
|
||
|
||
def export_cpa_after_register(email, password, session=None, sso="", log_callback=None):
|
||
"""注册成功后铸造 Grok Build OIDC,写出 CPA (CLIProxyAPI) 的 xai-<email>.json,
|
||
并按配置推送到远端 CLIProxyAPI 导入。
|
||
|
||
默认优先用 SSO cookie 走 Authorization Code + PKCE(referrer=grok-build),
|
||
不再依赖设备码浏览器确认。本地写到 config['cpa_auth_dir'],
|
||
远端推送到 config['cpa_remote_base'] 的 /v0/management/auth-files。
|
||
"""
|
||
log = log_callback or cli_log
|
||
if not config.get("cpa_export_enabled", True):
|
||
log("[cpa] 已关闭导出,跳过")
|
||
return {"ok": False, "skipped": True, "reason": "disabled"}
|
||
|
||
try:
|
||
import cpa_export
|
||
except Exception as exc:
|
||
log(f"[cpa] 导入 cpa_export 失败: {exc}")
|
||
if config.get("mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": f"import: {exc}"}
|
||
|
||
page = getattr(session, "page", None) if session is not None else None
|
||
try:
|
||
result = cpa_export.export_cpa_for_account(
|
||
email,
|
||
password,
|
||
page=page,
|
||
sso=sso or "",
|
||
config=config,
|
||
log_callback=log,
|
||
)
|
||
except Exception as exc:
|
||
log(f"[cpa] 导出异常: {exc}")
|
||
if config.get("mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": str(exc)}
|
||
|
||
if result.get("ok"):
|
||
tail = " 并已推送远端" if result.get("pushed") else ""
|
||
ref = result.get("referrer") or ""
|
||
ref_s = f" referrer={ref}" if ref else ""
|
||
log(f"[+] CPA 认证已写出: {result.get('path')}{ref_s}{tail}")
|
||
elif result.get("skipped"):
|
||
log(f"[cpa] 跳过: {result.get('reason')}")
|
||
else:
|
||
log(f"[!] CPA 导出未成功: {result.get('error') or result}")
|
||
return result
|
||
|
||
|
||
def cleanup_mint_browsers():
|
||
"""关闭铸造 worker 复用的独立 Chromium(线程退出时调用)。"""
|
||
try:
|
||
from oidc_mint import shutdown_mint_browsers
|
||
shutdown_mint_browsers()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _kill_proc_tree(pid, timeout=3):
|
||
"""强杀指定进程及其所有子进程(浏览器 quit 不干净时兜底)。"""
|
||
if not pid:
|
||
return
|
||
try:
|
||
parent = psutil.Process(pid)
|
||
except Exception:
|
||
return
|
||
procs = []
|
||
try:
|
||
procs = parent.children(recursive=True)
|
||
except Exception:
|
||
pass
|
||
procs.append(parent)
|
||
for p in procs:
|
||
try:
|
||
p.kill()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
psutil.wait_procs(procs, timeout=timeout)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _kill_chrome_session(pid=None, port=None):
|
||
"""精准杀掉某个浏览器会话:优先按调试端口定位真正的 chrome 主进程,再连启动器 pid 一起杀进程树。"""
|
||
targets = set()
|
||
if port:
|
||
marker = f"--remote-debugging-port={port}"
|
||
for p in psutil.process_iter(["name", "cmdline"]):
|
||
try:
|
||
if (p.info.get("name") or "").lower() == "chrome.exe" and marker in " ".join(p.info.get("cmdline") or []):
|
||
targets.add(p.pid)
|
||
except Exception:
|
||
continue
|
||
if pid:
|
||
targets.add(pid)
|
||
for tp in targets:
|
||
_kill_proc_tree(tp)
|
||
|
||
|
||
def cleanup_stray_chrome(log_callback=None, rounds=4):
|
||
"""退出兜底:杀掉带调试端口的孤儿 chrome 进程树 + GoogleUpdate 家族(多轮,清到杀不动为止)。
|
||
|
||
只杀命令行含 --remote-debugging-port 的 chrome(本程序拉起的),不碰你手动打开的 Chrome。
|
||
"""
|
||
log = log_callback or cli_log
|
||
updater = {"googleupdate.exe", "googlecrashhandler.exe",
|
||
"googlecrashhandler64.exe", "elevation_service.exe"}
|
||
total_chrome = 0
|
||
total_upd = 0
|
||
for _ in range(max(1, rounds)):
|
||
killed = 0
|
||
for p in psutil.process_iter(["name", "cmdline"]):
|
||
try:
|
||
name = (p.info.get("name") or "").lower()
|
||
if name in updater:
|
||
p.kill()
|
||
total_upd += 1
|
||
killed += 1
|
||
continue
|
||
if name == "chrome.exe":
|
||
cmd = " ".join(p.info.get("cmdline") or [])
|
||
if "--remote-debugging-port" in cmd:
|
||
for c in p.children(recursive=True):
|
||
try:
|
||
c.kill()
|
||
except Exception:
|
||
pass
|
||
p.kill()
|
||
total_chrome += 1
|
||
killed += 1
|
||
except Exception:
|
||
continue
|
||
if killed == 0:
|
||
break
|
||
time.sleep(0.5)
|
||
if total_chrome or total_upd:
|
||
log(f"[*] 清理残留:chrome {total_chrome} 组,GoogleUpdate {total_upd} 个")
|
||
|
||
|
||
def create_browser_options():
|
||
options = ChromiumOptions()
|
||
options.auto_port()
|
||
options.set_timeouts(base=1)
|
||
# 关键:全新临时用户目录下,Chrome 138 会走「首次启动」流程,DrissionPage 取不到
|
||
# 标签页而在 Chromium() 启动处卡死。--no-first-run 跳过首启,--no-default-browser-check
|
||
# 屏蔽「设为默认浏览器」提示;二者都不影响 Turnstile 渲染。
|
||
options.set_argument("--no-first-run")
|
||
options.set_argument("--no-default-browser-check")
|
||
# 禁用后台网络(含更新检查),减少 Chrome 拉起 GoogleUpdate.exe 残留
|
||
options.set_argument("--disable-background-networking")
|
||
options.set_argument("--disable-component-update")
|
||
# 禁用后台标签/窗口节流,避免多窗口时后台浏览器被降频、响应变慢
|
||
options.set_argument("--disable-background-timer-throttling")
|
||
options.set_argument("--disable-backgrounding-occluded-windows")
|
||
options.set_argument("--disable-renderer-backgrounding")
|
||
# 隐藏窗口(可选,默认关):仅移出屏幕、不缩小尺寸
|
||
if config.get("hide_window", False):
|
||
options.set_argument("--window-position=-32000,-32000")
|
||
# 加载 turnstilePatch 扩展(伪造自动化鼠标事件指纹,帮过 Turnstile)
|
||
if os.path.exists(EXTENSION_PATH):
|
||
options.add_extension(EXTENSION_PATH)
|
||
return options
|
||
|
||
|
||
def _build_request_kwargs(**kwargs):
|
||
request_kwargs = dict(kwargs)
|
||
proxies = request_kwargs.pop("proxies", None)
|
||
if proxies is None:
|
||
proxies = get_proxies()
|
||
if proxies:
|
||
request_kwargs["proxies"] = proxies
|
||
request_kwargs.setdefault("timeout", 15)
|
||
return request_kwargs
|
||
|
||
|
||
def http_get(url, **kwargs):
|
||
try:
|
||
return requests.get(url, **_build_request_kwargs(**kwargs))
|
||
except Exception as exc:
|
||
err = str(exc)
|
||
# 代理不可用时自动回退为直连,避免整个流程直接失败
|
||
if "127.0.0.1 port 7890" in err or "Could not connect to server" in err:
|
||
retry_kwargs = dict(kwargs)
|
||
retry_kwargs["proxies"] = {}
|
||
return requests.get(url, **_build_request_kwargs(**retry_kwargs))
|
||
raise
|
||
|
||
|
||
def http_post(url, **kwargs):
|
||
try:
|
||
return requests.post(url, **_build_request_kwargs(**kwargs))
|
||
except Exception as exc:
|
||
err = str(exc)
|
||
if "127.0.0.1 port 7890" in err or "Could not connect to server" in err:
|
||
retry_kwargs = dict(kwargs)
|
||
retry_kwargs["proxies"] = {}
|
||
return requests.post(url, **_build_request_kwargs(**retry_kwargs))
|
||
raise
|
||
|
||
|
||
def raise_if_cancelled(cancel_callback=None):
|
||
if cancel_callback and cancel_callback():
|
||
raise RegistrationCancelled("鐢ㄦ埛鍋滄娉ㄥ唽")
|
||
|
||
|
||
def sleep_with_cancel(seconds, cancel_callback=None):
|
||
deadline = time.time() + max(seconds, 0)
|
||
while True:
|
||
raise_if_cancelled(cancel_callback)
|
||
remaining = deadline - time.time()
|
||
if remaining <= 0:
|
||
return
|
||
time.sleep(min(0.2, remaining))
|
||
|
||
|
||
def get_domains(api_key=None):
|
||
headers = {}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
|
||
def create_account(address, password, api_key=None, expires_in=0):
|
||
headers = {"Content-Type": "application/json"}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
data = {"address": address, "password": password, "expiresIn": expires_in}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/accounts", json=data, headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def get_token(address, password):
|
||
data = {"address": address, "password": password}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/token", json=data)
|
||
resp.raise_for_status()
|
||
return resp.json().get("token")
|
||
|
||
|
||
def get_messages(token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
|
||
def get_message_detail(token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def cloudflare_get_domains(api_base, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=False)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_domains", "/domains")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return _pick_list_payload(resp.json())
|
||
|
||
|
||
def cloudflare_create_account(api_base, address, password, api_key=None, expires_in=0):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
payload = {"address": address, "password": password, "expiresIn": expires_in}
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/accounts")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_post(f"{api_base}{path}", json=payload, headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def cloudflare_get_token(api_base, address, password, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_token", "/token")
|
||
resp = http_post(
|
||
f"{api_base}{path}",
|
||
json={"address": address, "password": password},
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
if data.get("token"):
|
||
return data.get("token")
|
||
if isinstance(data.get("data"), dict) and data["data"].get("token"):
|
||
return data["data"].get("token")
|
||
return None
|
||
|
||
|
||
def cloudflare_get_messages(api_base, token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
path = get_cloudflare_path("cloudflare_path_messages", "/messages")
|
||
params = {"limit": 20, "offset": 0}
|
||
params = cloudflare_apply_auth_params(params)
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare messages 返回非JSON: {resp.text[:300]}")
|
||
return _pick_list_payload(data)
|
||
|
||
|
||
def cloudflare_get_message_detail(api_base, token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
candidates = [
|
||
f"{api_base}/api/mail/{message_id}",
|
||
f"{api_base}{get_cloudflare_path('cloudflare_path_messages', '/messages')}/{message_id}",
|
||
]
|
||
last_err = None
|
||
for url in candidates:
|
||
try:
|
||
resp = http_get(
|
||
url,
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict) and isinstance(data.get("data"), dict):
|
||
return data["data"]
|
||
return data
|
||
except Exception as exc:
|
||
last_err = exc
|
||
continue
|
||
raise Exception(f"Cloudflare 获取邮件详情失败: {last_err}")
|
||
|
||
|
||
YYDS_API_BASE = "https://maliapi.215.im/v1"
|
||
|
||
|
||
def get_yyds_api_key():
|
||
return config.get("yyds_api_key", "")
|
||
|
||
|
||
def get_yyds_jwt():
|
||
return config.get("yyds_jwt", "")
|
||
|
||
|
||
def yyds_get_domains(api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(f"{YYDS_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return data.get("data", []) if data.get("success") else []
|
||
|
||
|
||
def yyds_create_account(address=None, domain=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
payload = {}
|
||
if address:
|
||
payload["address"] = address
|
||
if domain:
|
||
payload["domain"] = domain
|
||
elif key or token:
|
||
payload["autoDomainStrategy"] = "prefer_owned"
|
||
resp = http_post(f"{YYDS_API_BASE}/accounts", json=payload, headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {})
|
||
raise Exception(f"YYDS 鍒涘缓閭澶辫触: {data}")
|
||
|
||
|
||
def yyds_get_token(address, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_post(
|
||
f"{YYDS_API_BASE}/token", json={"address": address}, headers=headers
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {}).get("token")
|
||
raise Exception(f"YYDS 鑾峰彇token澶辫触: {data}")
|
||
|
||
|
||
def yyds_get_messages(address, token=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
temp_token = token or jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if temp_token:
|
||
headers["Authorization"] = f"Bearer {temp_token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(
|
||
f"{YYDS_API_BASE}/messages",
|
||
params={"address": address},
|
||
headers=headers,
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {}).get("messages", [])
|
||
return []
|
||
|
||
|
||
def yyds_get_message_detail(message_id, token=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
temp_token = token or jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if temp_token:
|
||
headers["Authorization"] = f"Bearer {temp_token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(f"{YYDS_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {})
|
||
raise Exception(f"YYDS 鑾峰彇閭欢璇︽儏澶辫触: {data}")
|
||
|
||
|
||
def yyds_generate_username(length=10):
|
||
chars = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(chars) for _ in range(length))
|
||
|
||
|
||
def yyds_pick_domain(api_key=None, jwt=None):
|
||
domains = yyds_get_domains(api_key=api_key, jwt=jwt)
|
||
if not domains:
|
||
raise Exception("YYDS 娌℃湁杩斿洖浠讳綍鍙敤鍩熷悕")
|
||
private = [d for d in domains if d.get("isVerified") and not d.get("isPublic")]
|
||
if private:
|
||
return private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified") and d.get("isPublic")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
verified = [d for d in domains if d.get("isVerified")]
|
||
if verified:
|
||
return verified[0]["domain"]
|
||
raise Exception("YYDS 鏃犲凡楠岃瘉鍩熷悕鍙敤")
|
||
|
||
|
||
def yyds_get_email_and_token(api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
if not token and not key:
|
||
raise Exception("YYDS API Key 或 JWT 未配置")
|
||
domain = yyds_pick_domain(api_key=key, jwt=token)
|
||
username = yyds_generate_username(10)
|
||
result = yyds_create_account(
|
||
address=username, domain=domain, api_key=key, jwt=token
|
||
)
|
||
address = result.get("address") or f"{username}@{domain}"
|
||
temp_token = result.get("token")
|
||
if not temp_token:
|
||
temp_token = yyds_get_token(address, api_key=key, jwt=token)
|
||
if not temp_token:
|
||
raise Exception("鑾峰彇 YYDS token 澶辫触")
|
||
print(f"[*] 宸插垱寤?YYDS 閭: {address}")
|
||
return address, temp_token
|
||
|
||
|
||
def yyds_get_oai_code(
|
||
token,
|
||
address,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
jwt=None,
|
||
cancel_callback=None,
|
||
):
|
||
deadline = time.time() + timeout
|
||
seen_ids = set()
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
messages = yyds_get_messages(address, token=token, jwt=jwt)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 鎷夊彇閭欢鍒楄〃澶辫触: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
for msg in messages:
|
||
msg_id = msg.get("id")
|
||
if not msg_id or msg_id in seen_ids:
|
||
continue
|
||
seen_ids.add(msg_id)
|
||
to_addrs = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
if address.lower() not in to_addrs:
|
||
continue
|
||
try:
|
||
detail = yyds_get_message_detail(msg_id, token=token, jwt=jwt)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 鑾峰彇閭欢璇︽儏澶辫触: {exc}")
|
||
continue
|
||
parts = []
|
||
text_body = detail.get("text") or ""
|
||
if text_body:
|
||
parts.append(text_body)
|
||
html_list = detail.get("html") or []
|
||
for h in html_list:
|
||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||
combined = "\n".join(parts)
|
||
subject = detail.get("subject", "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 鏀跺埌閭欢: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 浠庨偖浠朵腑鎻愬彇鍒伴獙璇佺爜: {code}")
|
||
return code
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"YYDS 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
def generate_username(length=10):
|
||
chars = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(chars) for _ in range(length))
|
||
|
||
|
||
def pick_domain(api_key=None):
|
||
domains = get_domains(api_key=api_key)
|
||
if not domains:
|
||
raise Exception("DuckMail 娌℃湁杩斿洖浠讳綍鍙敤鍩熷悕")
|
||
private = [d for d in domains if d.get("ownerId")]
|
||
verified_private = [d for d in private if d.get("isVerified")]
|
||
if verified_private:
|
||
return verified_private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
raise Exception("DuckMail 鏃犲凡楠岃瘉鍩熷悕鍙敤")
|
||
|
||
|
||
def get_email_provider():
|
||
return str(config.get("email_provider", "duckmail") or "duckmail").strip().lower()
|
||
|
||
|
||
def _config_bool(value, default=False):
|
||
if value is None:
|
||
return default
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
return bool(value)
|
||
if isinstance(value, str):
|
||
v = value.strip().lower()
|
||
if v in ("1", "true", "yes", "y", "on"):
|
||
return True
|
||
if v in ("0", "false", "no", "n", "off"):
|
||
return False
|
||
return default
|
||
|
||
|
||
def _parse_domain_list(raw):
|
||
return [x.strip().lower() for x in re.split(r"[,,\s]+", str(raw or "")) if x.strip()]
|
||
|
||
|
||
def cf_worker_domain_set():
|
||
"""走 CF Email Routing Worker 取码的域名集合(非 temp-email)。"""
|
||
return set(_parse_domain_list(config.get("cf_worker_domains") or config.get("gmail_worker_domains") or ""))
|
||
|
||
|
||
def next_register_domain():
|
||
"""从 defaultDomains 轮换取下一个注册邮箱域名。"""
|
||
global _cf_domain_index
|
||
domains = _parse_domain_list(config.get("defaultDomains") or "")
|
||
if not domains:
|
||
return ""
|
||
with _cf_domain_lock:
|
||
domain = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
return domain
|
||
|
||
|
||
def is_cf_worker_token(dev_token):
|
||
tok = str(dev_token or "").strip().lower()
|
||
return tok in ("cf_worker", "gmail_catch_all", "cf_email_worker") or tok.startswith("cf_worker:")
|
||
|
||
|
||
def _gmail_worker_enabled():
|
||
if not _config_bool(config.get("gmail_worker_enabled", False), default=False):
|
||
return False
|
||
url = str(config.get("gmail_worker_url", "") or os.environ.get("GMAIL_WORKER_URL", "") or "").strip()
|
||
key = str(config.get("gmail_worker_api_key", "") or os.environ.get("GMAIL_WORKER_API_KEY", "") or "").strip()
|
||
return bool(url and key)
|
||
|
||
|
||
def _gmail_worker_base_url():
|
||
return str(config.get("gmail_worker_url", "") or os.environ.get("GMAIL_WORKER_URL", "") or "").strip().rstrip("/")
|
||
|
||
|
||
def _gmail_worker_api_key():
|
||
return str(config.get("gmail_worker_api_key", "") or os.environ.get("GMAIL_WORKER_API_KEY", "") or "").strip()
|
||
|
||
|
||
def gmail_worker_fetch_code(
|
||
email,
|
||
*,
|
||
timeout=25.0,
|
||
poll_interval=None,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
consume=True,
|
||
):
|
||
"""轮询 CF Email Worker GET /code?to=... 取验证码(非 temp-email)。"""
|
||
base = _gmail_worker_base_url()
|
||
key = _gmail_worker_api_key()
|
||
if not base or not key:
|
||
return None
|
||
if poll_interval is None:
|
||
try:
|
||
poll_interval = float(config.get("gmail_worker_poll_interval", 0.4) or 0.4)
|
||
except Exception:
|
||
poll_interval = 0.4
|
||
poll_interval = max(0.15, min(float(poll_interval), 3.0))
|
||
try:
|
||
timeout = float(timeout)
|
||
except Exception:
|
||
timeout = 25.0
|
||
deadline = time.time() + max(1.0, timeout)
|
||
target = (email or "").strip().lower()
|
||
if not target:
|
||
return None
|
||
t0 = time.time()
|
||
polls = 0
|
||
last_err = ""
|
||
url = f"{base}/code"
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
polls += 1
|
||
try:
|
||
# Worker 直连,不走注册代理
|
||
resp = http_get(
|
||
url,
|
||
params={
|
||
"to": target,
|
||
"key": key,
|
||
"consume": "1" if consume else "0",
|
||
},
|
||
headers={"x-api-key": key},
|
||
timeout=8,
|
||
proxies={},
|
||
)
|
||
if resp.status_code == 200:
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
code = str(data.get("code") or "").strip()
|
||
if code:
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] CF Worker 取到验证码: {code} "
|
||
f"(elapsed={time.time() - t0:.1f}s polls={polls})"
|
||
)
|
||
return code
|
||
elif resp.status_code == 404:
|
||
last_err = "not found"
|
||
elif resp.status_code in (401, 403):
|
||
last_err = f"auth {resp.status_code}"
|
||
if log_callback and polls <= 2:
|
||
log_callback(f"[Debug] CF Worker 鉴权失败 HTTP {resp.status_code}")
|
||
if polls >= 3:
|
||
return None
|
||
else:
|
||
last_err = f"HTTP {resp.status_code}"
|
||
if log_callback and polls <= 3:
|
||
log_callback(
|
||
f"[Debug] CF Worker 响应 {resp.status_code}: {(resp.text or '')[:120]}"
|
||
)
|
||
except Exception as exc:
|
||
last_err = str(exc)
|
||
if log_callback and polls <= 3:
|
||
log_callback(f"[Debug] CF Worker 请求失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] CF Worker {timeout:.0f}s 内未取到验证码 ({last_err or 'timeout'})"
|
||
)
|
||
return None
|
||
|
||
|
||
def cf_worker_get_email_and_token(domain=None):
|
||
"""CF catch-all 域名:本地生成别名,不调用 temp-email 创建接口。"""
|
||
domains = _parse_domain_list(config.get("defaultDomains") or "")
|
||
worker_domains = cf_worker_domain_set()
|
||
if domain:
|
||
use_domain = domain.strip().lower()
|
||
elif worker_domains:
|
||
# 优先 worker 域名列表与 defaultDomains 交集
|
||
candidates = [d for d in domains if d in worker_domains] or list(worker_domains)
|
||
use_domain = candidates[0]
|
||
else:
|
||
if not domains:
|
||
raise Exception("CF Worker 模式需要 defaultDomains,如 zhangyunuo.net")
|
||
use_domain = domains[0]
|
||
username = generate_username(10)
|
||
address = f"{username}@{use_domain}"
|
||
return address, "cf_worker"
|
||
|
||
|
||
def cf_worker_get_oai_code(
|
||
email,
|
||
timeout=180,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
"""优先 CF Worker HTTP 取码;可选 IMAP 兜底(需配置 gmail_imap_*)。"""
|
||
if not _gmail_worker_enabled():
|
||
raise Exception(
|
||
"域名走 CF Worker 取码,但未配置 gmail_worker_enabled/url/api_key"
|
||
)
|
||
try:
|
||
worker_timeout = float(config.get("gmail_worker_timeout_sec", 20) or 20)
|
||
except Exception:
|
||
worker_timeout = 20.0
|
||
worker_timeout = max(4.0, min(worker_timeout, float(timeout)))
|
||
if log_callback:
|
||
log_callback(f"[*] 取码:CF Email Worker(timeout={worker_timeout:.0f}s){email}")
|
||
code = gmail_worker_fetch_code(
|
||
email,
|
||
timeout=worker_timeout,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
consume=True,
|
||
)
|
||
if code:
|
||
return code
|
||
|
||
# 可选:Worker 未命中时触发重发再等一轮
|
||
if resend_callback:
|
||
try:
|
||
resend_callback()
|
||
if log_callback:
|
||
log_callback("[*] CF Worker 未命中,已触发重发验证码,再等一轮")
|
||
except Exception:
|
||
pass
|
||
code = gmail_worker_fetch_code(
|
||
email,
|
||
timeout=min(15.0, worker_timeout),
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
consume=True,
|
||
)
|
||
if code:
|
||
return code
|
||
|
||
raise Exception(f"CF Worker 在 {worker_timeout:.0f}s 内未收到验证码: {email}")
|
||
|
||
|
||
def get_email_and_token(api_key=None):
|
||
provider = get_email_provider()
|
||
if provider == "yyds":
|
||
return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt())
|
||
if provider in ("gmail", "cf_worker", "cf-email-worker"):
|
||
return cf_worker_get_email_and_token()
|
||
if provider == "cloudflare":
|
||
# 双通道:defaultDomains 里 worker 域名本地生成;其余走 temp-email
|
||
domain = next_register_domain()
|
||
worker_domains = cf_worker_domain_set()
|
||
if domain and domain in worker_domains:
|
||
if not _gmail_worker_enabled():
|
||
raise Exception(
|
||
f"域名 {domain} 配置为 CF Worker,但 gmail_worker_* 未就绪"
|
||
)
|
||
return cf_worker_get_email_and_token(domain=domain)
|
||
api_base = get_cloudflare_api_base()
|
||
if not api_base:
|
||
raise Exception("Cloudflare API Base 未配置")
|
||
try:
|
||
return cloudflare_create_temp_address(api_base, preferred_domain=domain or None)
|
||
except Exception as primary_exc:
|
||
# 若 temp-email 失败且 worker 可用,可回退到 worker 域名
|
||
if worker_domains and _gmail_worker_enabled():
|
||
fallback = next(iter(worker_domains))
|
||
return cf_worker_get_email_and_token(domain=fallback)
|
||
key = api_key or get_cloudflare_api_key()
|
||
domains = cloudflare_get_domains(api_base, api_key=key)
|
||
if not domains:
|
||
raise Exception(f"Cloudflare 创建邮箱失败: {primary_exc}")
|
||
verified = [d for d in domains if d.get("isVerified")]
|
||
target = verified[0] if verified else domains[0]
|
||
domain = target.get("domain")
|
||
if not domain:
|
||
raise Exception("Cloudflare 域名数据格式错误,缺少 domain 字段")
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
password = secrets.token_urlsafe(12)
|
||
cloudflare_create_account(
|
||
api_base, address, password, api_key=key, expires_in=0
|
||
)
|
||
token = cloudflare_get_token(api_base, address, password, api_key=key)
|
||
if not token:
|
||
raise Exception("获取 Cloudflare 邮箱 token 失败")
|
||
return address, token
|
||
key = api_key or get_duckmail_api_key()
|
||
domain = pick_domain(api_key=key)
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
password = secrets.token_urlsafe(12)
|
||
create_account(address, password, api_key=key, expires_in=0)
|
||
token = get_token(address, password)
|
||
if not token:
|
||
raise Exception("获取 DuckMail token 失败")
|
||
return address, token
|
||
|
||
|
||
def get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
provider = get_email_provider()
|
||
# 按 token / 收件域名分流:worker 域名绝不走 temp-email 拉信
|
||
email_domain = ""
|
||
if email and "@" in email:
|
||
email_domain = email.rsplit("@", 1)[-1].strip().lower()
|
||
if (
|
||
is_cf_worker_token(dev_token)
|
||
or provider in ("gmail", "cf_worker", "cf-email-worker")
|
||
or (email_domain and email_domain in cf_worker_domain_set())
|
||
):
|
||
return cf_worker_get_oai_code(
|
||
email,
|
||
timeout=timeout,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
if provider == "yyds":
|
||
return yyds_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
jwt=get_yyds_jwt(),
|
||
cancel_callback=cancel_callback,
|
||
)
|
||
if provider == "cloudflare":
|
||
return cloudflare_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
return duckmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
)
|
||
|
||
|
||
def extract_verification_code(text, subject=""):
|
||
if subject:
|
||
match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
patterns = [
|
||
r"verification\s+code[:\s]+(\d{4,8})",
|
||
r"your\s+code[:\s]+(\d{4,8})",
|
||
r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})",
|
||
]
|
||
for pattern in patterns:
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
return None
|
||
|
||
|
||
def duckmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
):
|
||
deadline = time.time() + timeout
|
||
seen_ids = set()
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
messages = get_messages(dev_token)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 鎷夊彇閭欢鍒楄〃澶辫触: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
for msg in messages:
|
||
msg_id = msg.get("id") or msg.get("msgid")
|
||
if not msg_id or msg_id in seen_ids:
|
||
continue
|
||
seen_ids.add(msg_id)
|
||
recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
if email.lower() not in recipients:
|
||
continue
|
||
try:
|
||
detail = get_message_detail(dev_token, msg_id)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 鑾峰彇閭欢璇︽儏澶辫触: {exc}")
|
||
continue
|
||
parts = []
|
||
text_body = detail.get("text") or ""
|
||
if text_body:
|
||
parts.append(text_body)
|
||
html_list = detail.get("html") or []
|
||
for h in html_list:
|
||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||
combined = "\n".join(parts)
|
||
subject = detail.get("subject", "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] 鏀跺埌閭欢: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 浠庨偖浠朵腑鎻愬彇鍒伴獙璇佺爜: {code}")
|
||
return code
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
def cloudflare_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
api_base = get_cloudflare_api_base()
|
||
if not api_base:
|
||
raise Exception("Cloudflare API Base 未配置")
|
||
deadline = time.time() + timeout
|
||
# 同一封邮件正文可能延迟可读,允许多次重试解析,避免偶发漏码
|
||
seen_attempts = {}
|
||
next_resend_at = time.time() + 35
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if resend_callback and time.time() >= next_resend_at:
|
||
try:
|
||
resend_callback()
|
||
if log_callback:
|
||
log_callback("[Debug] 已触发重新发送验证码")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 触发重发验证码失败: {exc}")
|
||
next_resend_at = time.time() + 35
|
||
try:
|
||
messages = cloudflare_get_messages(api_base, dev_token)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 本轮邮件数量: {len(messages)}")
|
||
|
||
for msg in messages:
|
||
msg_id = msg.get("id") or msg.get("msgid")
|
||
if not msg_id:
|
||
continue
|
||
attempt = int(seen_attempts.get(msg_id, 0))
|
||
if attempt >= 5:
|
||
continue
|
||
seen_attempts[msg_id] = attempt + 1
|
||
recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
msg_addr = str(msg.get("address", "")).lower()
|
||
# 优先匹配目标邮箱;若结构不一致也允许继续解析,避免接口字段漂移导致漏码
|
||
address_matched = True
|
||
if recipients:
|
||
address_matched = email.lower() in recipients
|
||
elif msg_addr:
|
||
address_matched = msg_addr == email.lower()
|
||
if not address_matched and log_callback:
|
||
log_callback(f"[Debug] 跳过疑似非目标邮件 id={msg_id} address={msg_addr} to={recipients}")
|
||
continue
|
||
parts = []
|
||
# 先直接从列表项取内容,避免 detail 接口差异导致漏码
|
||
for field in ("text", "raw", "content", "intro", "body", "snippet"):
|
||
value = msg.get(field)
|
||
if isinstance(value, str) and value.strip():
|
||
parts.append(value)
|
||
html_list = msg.get("html") or []
|
||
if isinstance(html_list, str):
|
||
html_list = [html_list]
|
||
for h in html_list:
|
||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||
subject = str(msg.get("subject", "") or "")
|
||
combined = "\n".join(parts)
|
||
# 再尝试 detail 接口补全内容
|
||
try:
|
||
detail = cloudflare_get_message_detail(api_base, dev_token, msg_id)
|
||
for field in ("text", "raw", "content", "intro", "body", "snippet"):
|
||
value = detail.get(field)
|
||
if isinstance(value, str) and value.strip():
|
||
combined += "\n" + value
|
||
html_list2 = detail.get("html") or []
|
||
if isinstance(html_list2, str):
|
||
html_list2 = [html_list2]
|
||
for h in html_list2:
|
||
combined += "\n" + re.sub(r"<[^>]+>", " ", h)
|
||
if not subject:
|
||
subject = str(detail.get("subject", "") or "")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare detail接口失败,改用列表内容解析: {exc}")
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 从邮件中提取到验证码: {code}")
|
||
return code
|
||
elif log_callback:
|
||
log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"Cloudflare 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
def generate_random_birthdate():
|
||
import datetime as dt
|
||
|
||
today = dt.date.today()
|
||
age = random.randint(20, 40)
|
||
birth_year = today.year - age
|
||
birth_month = random.randint(1, 12)
|
||
birth_day = random.randint(1, 28)
|
||
return f"{birth_year}-{birth_month:02d}-{birth_day:02d}T16:00:00.000Z"
|
||
|
||
|
||
def response_preview(res, limit=200):
|
||
try:
|
||
text = str(res.text or "")
|
||
except Exception:
|
||
text = ""
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
return text[:limit]
|
||
|
||
|
||
def is_cloudflare_block_response(res):
|
||
try:
|
||
headers = {str(k).lower(): str(v).lower() for k, v in dict(res.headers).items()}
|
||
text = str(res.text or "").lower()
|
||
server = headers.get("server", "")
|
||
content_type = headers.get("content-type", "")
|
||
return (
|
||
res.status_code in (403, 429, 503)
|
||
and (
|
||
"cloudflare" in server
|
||
or "cloudflare" in text
|
||
or "cf-error" in text
|
||
or "__cf_chl" in text
|
||
or "text/html" in content_type
|
||
)
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def set_birth_date(session, log_callback=None):
|
||
url = "https://grok.com/rest/auth/set-birth-date"
|
||
new_headers = {
|
||
"content-type": "application/json",
|
||
"origin": "https://grok.com",
|
||
"referer": "https://grok.com/",
|
||
}
|
||
payload = {"birthDate": generate_random_birthdate()}
|
||
try:
|
||
res = session.post(url, json=payload, headers=new_headers, timeout=15)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] set_birth_date status: {res.status_code}, body: {response_preview(res)}"
|
||
)
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
if is_cloudflare_block_response(res):
|
||
return (
|
||
False,
|
||
"set_birth_date 被 grok.com 的 Cloudflare 防护拦截,HTTP "
|
||
f"{res.status_code}",
|
||
)
|
||
return False, f"set_birth_date HTTP {res.status_code}: {response_preview(res)}"
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[set_birth_date] 异常: {e}")
|
||
return False, f"set_birth_date 异常: {e}"
|
||
|
||
|
||
def set_tos_accepted(session, log_callback=None):
|
||
url = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion"
|
||
payload = struct.pack("B", (2 << 3) | 0) + struct.pack("B", 1)
|
||
data = b"\x00" + struct.pack(">I", len(payload)) + payload
|
||
new_headers = {
|
||
"content-type": "application/grpc-web+proto",
|
||
"x-grpc-web": "1",
|
||
"x-user-agent": "connect-es/2.1.1",
|
||
"origin": "https://accounts.x.ai",
|
||
"referer": "https://accounts.x.ai/accept-tos",
|
||
}
|
||
try:
|
||
res = session.post(url, data=data, headers=new_headers, timeout=15)
|
||
if log_callback:
|
||
log_callback(f"[Debug] set_tos_accepted status: {res.status_code}")
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
if is_cloudflare_block_response(res):
|
||
return (
|
||
False,
|
||
"set_tos_accepted 被 accounts.x.ai 的 Cloudflare 防护拦截,HTTP "
|
||
f"{res.status_code}",
|
||
)
|
||
return False, f"set_tos_accepted HTTP {res.status_code}: {response_preview(res)}"
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[set_tos_accepted] 异常: {e}")
|
||
return False, f"set_tos_accepted 异常: {e}"
|
||
|
||
|
||
def encode_grpc_nsfw_settings():
|
||
field1_content = bytes([0x10, 0x01])
|
||
field1 = bytes([0x0A, len(field1_content)]) + field1_content
|
||
nsfw_string = b"always_show_nsfw_content"
|
||
field2_inner = bytes([0x0A, len(nsfw_string)]) + nsfw_string
|
||
field2 = bytes([0x12, len(field2_inner)]) + field2_inner
|
||
payload = field1 + field2
|
||
return b"\x00" + struct.pack(">I", len(payload)) + payload
|
||
|
||
|
||
def update_nsfw_settings(session, log_callback=None):
|
||
url = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls"
|
||
data = encode_grpc_nsfw_settings()
|
||
new_headers = {
|
||
"content-type": "application/grpc-web+proto",
|
||
"x-grpc-web": "1",
|
||
"origin": "https://grok.com",
|
||
"referer": "https://grok.com/",
|
||
}
|
||
try:
|
||
res = session.post(url, data=data, headers=new_headers, timeout=15)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] update_nsfw status: {res.status_code}, body: {response_preview(res)}"
|
||
)
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
if is_cloudflare_block_response(res):
|
||
return (
|
||
False,
|
||
"update_nsfw_settings 被 grok.com 的 Cloudflare 防护拦截,HTTP "
|
||
f"{res.status_code}",
|
||
)
|
||
return False, f"update_nsfw_settings HTTP {res.status_code}: {response_preview(res)}"
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[update_nsfw] 异常: {e}")
|
||
return False, f"update_nsfw_settings 异常: {e}"
|
||
|
||
|
||
def enable_nsfw_for_token(token, cf_clearance="", log_callback=None):
|
||
proxies = get_proxies()
|
||
user_agent = get_user_agent()
|
||
try:
|
||
with requests.Session(impersonate="chrome120", proxies=proxies) as session:
|
||
cookie_parts = [f"sso={token}", f"sso-rw={token}"]
|
||
if cf_clearance:
|
||
cookie_parts.append(f"cf_clearance={cf_clearance}")
|
||
session.headers.update(
|
||
{
|
||
"user-agent": user_agent,
|
||
"cookie": "; ".join(cookie_parts),
|
||
}
|
||
)
|
||
ok, message = set_tos_accepted(session, log_callback)
|
||
if not ok:
|
||
return False, message
|
||
ok, message = set_birth_date(session, log_callback)
|
||
if not ok:
|
||
return False, message
|
||
ok, message = update_nsfw_settings(session, log_callback)
|
||
if not ok:
|
||
return False, message
|
||
return True, "成功开启 NSFW"
|
||
except Exception as e:
|
||
return False, f"异常: {str(e)}"
|
||
|
||
|
||
SIGNUP_URL = "https://accounts.x.ai/sign-up?redirect=grok-com"
|
||
|
||
|
||
class BrowserSession:
|
||
"""每个并发 worker 独立持有一个浏览器实例,取代原来的全局 browser/page。"""
|
||
|
||
# 屏蔽的静态资源(省带宽);刻意不含 Turnstile 依赖的脚本/域名
|
||
BLOCKED_URLS = [
|
||
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.svg", "*.ico", "*.bmp",
|
||
"*.woff", "*.woff2", "*.ttf", "*.otf", "*.eot",
|
||
"*.mp4", "*.webm", "*.m4v", "*.mp3", "*.ogg", "*.wav",
|
||
]
|
||
|
||
# 内联 turnstilePatch 扩展的核心补丁:在每个页面/子 frame 的最早期伪造
|
||
# MouseEvent 的 screenX/screenY(随机屏幕基准 + clientX/Y),堵住 Cloudflare
|
||
# 检测自动化鼠标事件的通道。用 add_init_js 全局注入,无需外部 turnstilePatch 目录。
|
||
STEALTH_INIT_JS = r"""
|
||
(function () {
|
||
try {
|
||
function _rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||
var _sx = _rnd(800, 1200);
|
||
var _sy = _rnd(400, 600);
|
||
Object.defineProperty(MouseEvent.prototype, 'screenX', {
|
||
configurable: true,
|
||
get: function () { return _sx + this.clientX; }
|
||
});
|
||
Object.defineProperty(MouseEvent.prototype, 'screenY', {
|
||
configurable: true,
|
||
get: function () { return _sy + this.clientY; }
|
||
});
|
||
} catch (e) {}
|
||
})();
|
||
"""
|
||
|
||
def __init__(self, log_callback=None):
|
||
self.browser = None
|
||
self.page = None
|
||
self.log_callback = log_callback
|
||
|
||
def _log(self, message):
|
||
if self.log_callback:
|
||
self.log_callback(message)
|
||
|
||
def _apply_bandwidth_rules(self):
|
||
# 页面级拦截字体/媒体等静态资源省带宽(可选,默认关;可能影响验证渲染)
|
||
if self.page is None or not config.get("block_media_fonts", False):
|
||
return
|
||
try:
|
||
self.page.set.blocked_urls(self.BLOCKED_URLS)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_stealth_patch(self):
|
||
# 内联 turnstilePatch 补丁(可选,默认关;会改主文档指纹,可能弄巧成拙)
|
||
if self.page is None or not config.get("stealth_patch", False):
|
||
return
|
||
try:
|
||
self.page.add_init_js(self.STEALTH_INIT_JS)
|
||
except Exception:
|
||
pass
|
||
|
||
def start(self):
|
||
last_exc = None
|
||
for attempt in range(1, 5):
|
||
try:
|
||
self.browser = Chromium(create_browser_options())
|
||
try:
|
||
self._pid = self.browser.process_id
|
||
except Exception:
|
||
self._pid = None
|
||
try:
|
||
self._port = str(self.browser.address).rsplit(":", 1)[-1].strip()
|
||
except Exception:
|
||
self._port = None
|
||
tabs = self.browser.get_tabs()
|
||
self.page = tabs[-1] if tabs else self.browser.new_tab()
|
||
# 仅当开启了页面级钩子时才预热(确保 CDP 就绪);
|
||
# 默认全关时不预热,start 行为与最初脚本完全一致
|
||
if config.get("block_media_fonts", False) or config.get(
|
||
"stealth_patch", False
|
||
):
|
||
try:
|
||
self.page.get("about:blank")
|
||
except Exception:
|
||
pass
|
||
self._apply_bandwidth_rules()
|
||
self._apply_stealth_patch()
|
||
if attempt > 1:
|
||
self._log(f"[Debug] 浏览器第 {attempt} 次启动成功")
|
||
return self.browser, self.page
|
||
except Exception as exc:
|
||
last_exc = exc
|
||
self._log(f"[Debug] 浏览器启动失败(第{attempt}/4次): {exc}")
|
||
try:
|
||
if self.browser is not None:
|
||
self.browser.quit(del_data=True)
|
||
except Exception:
|
||
pass
|
||
self.browser = None
|
||
self.page = None
|
||
time.sleep(min(1.5 * attempt, 4))
|
||
raise Exception(f"浏览器启动失败,已重试4次: {last_exc}")
|
||
|
||
def stop(self):
|
||
pid = getattr(self, "_pid", None)
|
||
port = getattr(self, "_port", None)
|
||
if self.browser is not None:
|
||
try:
|
||
self.browser.quit(del_data=True)
|
||
except Exception:
|
||
pass
|
||
# quit 关不干净时兜底:按调试端口定位真正的 chrome 主进程并强杀整棵树
|
||
_kill_chrome_session(pid, port)
|
||
self.browser = None
|
||
self.page = None
|
||
self._pid = None
|
||
self._port = None
|
||
|
||
def restart(self):
|
||
self.stop()
|
||
gc.collect()
|
||
return self.start()
|
||
|
||
def refresh_page(self):
|
||
if self.browser is None:
|
||
self.restart()
|
||
return self.page
|
||
try:
|
||
tabs = self.browser.get_tabs()
|
||
self.page = tabs[-1] if tabs else self.browser.new_tab()
|
||
self._apply_bandwidth_rules()
|
||
except Exception:
|
||
self.restart()
|
||
return self.page
|
||
|
||
|
||
def click_email_signup_button(session, timeout=10, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if log_callback:
|
||
log_callback("[Debug] 尝试查找“使用邮箱注册”按钮...")
|
||
|
||
clicked = page.run_js(r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function nodeText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('href'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function scoreEntry(node) {
|
||
const compact = nodeText(node).replace(/\s+/g, '');
|
||
const lower = compact.toLowerCase();
|
||
if (compact.includes('使用邮箱注册')) return 100;
|
||
if (lower.includes('signupwithemail')) return 95;
|
||
if (lower.includes('continuewithemail')) return 90;
|
||
if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80;
|
||
if (lower === 'email' || lower.includes('邮箱')) return 70;
|
||
return 0;
|
||
}
|
||
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
||
.map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) }))
|
||
.filter((item) => item.score > 0)
|
||
.sort((a, b) => b.score - a.score);
|
||
const target = candidates[0]?.node || null;
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
target.click();
|
||
return candidates[0].text || true;
|
||
""")
|
||
|
||
if clicked:
|
||
if log_callback:
|
||
detail = f": {clicked}" if isinstance(clicked, str) else ""
|
||
log_callback(f"[Debug] 已点击「使用邮箱注册」按钮{detail}")
|
||
sleep_with_cancel(2, cancel_callback)
|
||
return True
|
||
|
||
if log_callback:
|
||
current_url = page.url if page else "none"
|
||
log_callback(f"[Debug] 当前URL: {current_url}")
|
||
|
||
sleep_with_cancel(1, cancel_callback)
|
||
|
||
if log_callback:
|
||
page_html = page.html[:500] if page else "no page"
|
||
log_callback(f"[Debug] 页面内容片段: {page_html}")
|
||
|
||
raise Exception("未找到「使用邮箱注册」按钮")
|
||
|
||
|
||
def open_signup_page(session, log_callback=None, cancel_callback=None):
|
||
raise_if_cancelled(cancel_callback)
|
||
if session.browser is None:
|
||
session.start()
|
||
if log_callback:
|
||
log_callback("[Debug] 浏览器已启动")
|
||
try:
|
||
page = session.browser.get_tab(0)
|
||
page.get(SIGNUP_URL)
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 打开URL异常: {e}")
|
||
try:
|
||
page = session.browser.new_tab(SIGNUP_URL)
|
||
except Exception as e2:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 创建新标签页异常: {e2}")
|
||
session.restart()
|
||
page = session.browser.new_tab(SIGNUP_URL)
|
||
session.page = page
|
||
session._apply_bandwidth_rules()
|
||
page.wait.doc_loaded()
|
||
sleep_with_cancel(2, cancel_callback)
|
||
if log_callback:
|
||
log_callback(f"[Debug] 当前URL: {page.url}")
|
||
click_email_signup_button(
|
||
session, log_callback=log_callback, cancel_callback=cancel_callback
|
||
)
|
||
|
||
|
||
def has_profile_form(session, log_callback=None):
|
||
session.refresh_page()
|
||
page = session.page
|
||
try:
|
||
return bool(
|
||
page.run_js(
|
||
"""
|
||
const givenInput = document.querySelector('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"]');
|
||
const familyInput = document.querySelector('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"]');
|
||
const passwordInput = document.querySelector('input[data-testid="password"], input[name="password"], input[type="password"]');
|
||
return !!(givenInput && familyInput && passwordInput);
|
||
"""
|
||
)
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def fill_email_and_submit(session, timeout=45, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
raise_if_cancelled(cancel_callback)
|
||
email, dev_token = get_email_and_token()
|
||
if not email or not dev_token:
|
||
raise Exception("获取邮箱失败")
|
||
if log_callback:
|
||
log_callback(f"[Debug] 已创建邮箱: {email}")
|
||
deadline = time.time() + timeout
|
||
last_diag_time = 0
|
||
last_reclick_time = 0
|
||
last_snapshot = None
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
filled = page.run_js(
|
||
"""
|
||
const email = arguments[0];
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function textOf(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('placeholder'),
|
||
node.getAttribute('data-testid'),
|
||
node.getAttribute('name'),
|
||
node.getAttribute('id'),
|
||
node.getAttribute('autocomplete'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function describeInput(node) {
|
||
return [
|
||
`type=${node.getAttribute('type') || ''}`,
|
||
`name=${node.getAttribute('name') || ''}`,
|
||
`id=${node.getAttribute('id') || ''}`,
|
||
`placeholder=${node.getAttribute('placeholder') || ''}`,
|
||
`aria=${node.getAttribute('aria-label') || ''}`,
|
||
`testid=${node.getAttribute('data-testid') || ''}`,
|
||
].join(' ').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||
}
|
||
function describeAction(node) {
|
||
return textOf(node).slice(0, 120);
|
||
}
|
||
function emailCandidates() {
|
||
const direct = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"], input[placeholder*="mail" i], input[aria-label*="mail" i]'));
|
||
const all = Array.from(document.querySelectorAll('input, textarea'));
|
||
for (const node of all) {
|
||
const type = (node.getAttribute('type') || '').toLowerCase();
|
||
if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'search'].includes(type)) continue;
|
||
const meta = textOf(node).toLowerCase();
|
||
if (meta.includes('email') || meta.includes('e-mail') || meta.includes('mail') || meta.includes('邮箱') || meta.includes('电子邮件')) {
|
||
direct.push(node);
|
||
}
|
||
}
|
||
return Array.from(new Set(direct));
|
||
}
|
||
const visibleInputs = Array.from(document.querySelectorAll('input, textarea'))
|
||
.filter((node) => isVisible(node) && !node.disabled && !node.readOnly)
|
||
.map(describeInput)
|
||
.slice(0, 8);
|
||
const visibleActions = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
||
.map(describeAction)
|
||
.filter(Boolean)
|
||
.slice(0, 10);
|
||
const input = emailCandidates().find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null;
|
||
if (!input) {
|
||
return {
|
||
state: 'not-ready',
|
||
url: location.href,
|
||
title: document.title,
|
||
inputs: visibleInputs,
|
||
buttons: visibleActions,
|
||
};
|
||
}
|
||
input.focus(); input.click();
|
||
const valueProto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const valueSetter = Object.getOwnPropertyDescriptor(valueProto, 'value')?.set;
|
||
const tracker = input._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (valueSetter) valueSetter.call(input, email); else input.value = email;
|
||
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: email, inputType: 'insertText' }));
|
||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: email, inputType: 'insertText' }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
const inputType = (input.getAttribute('type') || '').toLowerCase();
|
||
const isValid = inputType !== 'email' || input.checkValidity();
|
||
if ((input.value || '').trim() !== email || !isValid) {
|
||
return {
|
||
state: 'fill-failed',
|
||
value: input.value || '',
|
||
valid: isValid,
|
||
input: describeInput(input),
|
||
url: location.href,
|
||
};
|
||
}
|
||
input.blur();
|
||
return {
|
||
state: 'filled',
|
||
input: describeInput(input),
|
||
url: location.href,
|
||
};
|
||
""",
|
||
email,
|
||
)
|
||
state = filled.get("state") if isinstance(filled, dict) else filled
|
||
if isinstance(filled, dict):
|
||
last_snapshot = filled
|
||
if state == "not-ready":
|
||
now = time.time()
|
||
if now - last_reclick_time >= 3:
|
||
reclicked = page.run_js(r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function nodeText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('href'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function scoreEntry(node) {
|
||
const compact = nodeText(node).replace(/\s+/g, '');
|
||
const lower = compact.toLowerCase();
|
||
if (compact.includes('使用邮箱注册')) return 100;
|
||
if (lower.includes('signupwithemail')) return 95;
|
||
if (lower.includes('continuewithemail')) return 90;
|
||
if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80;
|
||
if (lower === 'email' || lower.includes('邮箱')) return 70;
|
||
return 0;
|
||
}
|
||
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
||
.map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) }))
|
||
.filter((item) => item.score > 0)
|
||
.sort((a, b) => b.score - a.score);
|
||
if (!candidates.length) return false;
|
||
candidates[0].node.click();
|
||
return candidates[0].text || true;
|
||
""")
|
||
last_reclick_time = now
|
||
if reclicked and log_callback:
|
||
detail = f": {reclicked}" if isinstance(reclicked, str) else ""
|
||
log_callback(f"[Debug] 邮箱输入框未出现,已再次触发邮箱注册入口{detail}")
|
||
if log_callback and now - last_diag_time >= 5:
|
||
last_diag_time = now
|
||
inputs = " | ".join((filled or {}).get("inputs", [])[:6]) if isinstance(filled, dict) else ""
|
||
buttons = " | ".join((filled or {}).get("buttons", [])[:8]) if isinstance(filled, dict) else ""
|
||
url = (filled or {}).get("url", page.url if page else "") if isinstance(filled, dict) else (page.url if page else "")
|
||
log_callback(f"[Debug] 等待邮箱输入框: url={url}; inputs={inputs or 'none'}; buttons={buttons or 'none'}")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
if state != "filled":
|
||
if log_callback:
|
||
log_callback(f"[Debug] 邮箱输入框已出现,但写入失败: {filled}")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
sleep_with_cancel(0.8, cancel_callback)
|
||
clicked = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function textOf(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('placeholder'),
|
||
node.getAttribute('data-testid'),
|
||
node.getAttribute('name'),
|
||
node.getAttribute('id'),
|
||
node.getAttribute('autocomplete'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function emailCandidates() {
|
||
const direct = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"], input[placeholder*="mail" i], input[aria-label*="mail" i]'));
|
||
const all = Array.from(document.querySelectorAll('input, textarea'));
|
||
for (const node of all) {
|
||
const type = (node.getAttribute('type') || '').toLowerCase();
|
||
if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'search'].includes(type)) continue;
|
||
const meta = textOf(node).toLowerCase();
|
||
if (meta.includes('email') || meta.includes('e-mail') || meta.includes('mail') || meta.includes('邮箱') || meta.includes('电子邮件')) {
|
||
direct.push(node);
|
||
}
|
||
}
|
||
return Array.from(new Set(direct));
|
||
}
|
||
const input = emailCandidates().find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null;
|
||
if (!input || !(input.value || '').trim()) return false;
|
||
const inputType = (input.getAttribute('type') || '').toLowerCase();
|
||
if (inputType === 'email' && !input.checkValidity()) return false;
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||
const submitButton = buttons.find((node) => {
|
||
const text = textOf(node).replace(/\s+/g, '');
|
||
const lower = text.toLowerCase();
|
||
return (
|
||
text === '注册' ||
|
||
text.includes('注册') ||
|
||
text.includes('继续') ||
|
||
text.includes('下一步') ||
|
||
text.includes('确认') ||
|
||
lower.includes('signup') ||
|
||
lower.includes('sign up') ||
|
||
lower.includes('continue') ||
|
||
lower.includes('next') ||
|
||
lower.includes('createaccount') ||
|
||
lower.includes('submit')
|
||
);
|
||
});
|
||
if (submitButton) {
|
||
submitButton.click();
|
||
return textOf(submitButton) || true;
|
||
}
|
||
const form = input.closest('form');
|
||
if (form) {
|
||
if (form.requestSubmit) form.requestSubmit();
|
||
else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||
return 'form-submit';
|
||
}
|
||
input.focus();
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
||
return 'enter';
|
||
"""
|
||
)
|
||
if clicked:
|
||
if log_callback:
|
||
detail = f" ({clicked})" if isinstance(clicked, str) else ""
|
||
log_callback(f"[Debug] 已填写邮箱并提交: {email}{detail}")
|
||
return email, dev_token
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
if last_snapshot:
|
||
inputs = " | ".join(last_snapshot.get("inputs", [])[:6])
|
||
buttons = " | ".join(last_snapshot.get("buttons", [])[:8])
|
||
url = last_snapshot.get("url", page.url if page else "")
|
||
raise Exception(
|
||
f"未找到邮箱输入框或注册按钮,最后页面: url={url}; inputs={inputs or 'none'}; buttons={buttons or 'none'}"
|
||
)
|
||
raise Exception("未找到邮箱输入框或注册按钮")
|
||
|
||
|
||
def fill_code_and_submit(session, email, dev_token, timeout=180, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
|
||
def _resend_code():
|
||
page.run_js(
|
||
r"""
|
||
const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
||
const target = nodes.find((node) => {
|
||
const t = (node.innerText || node.textContent || '').replace(/\s+/g, '').toLowerCase();
|
||
return t.includes('重新发送') || t.includes('resend') || t.includes('再次发送');
|
||
});
|
||
if (target && !target.disabled) { target.click(); return true; }
|
||
return false;
|
||
"""
|
||
)
|
||
|
||
code = get_oai_code(
|
||
dev_token,
|
||
email,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=_resend_code,
|
||
)
|
||
if not code:
|
||
raise Exception("获取验证码失败")
|
||
clean_code = str(code).replace("-", "").strip()
|
||
deadline = time.time() + timeout
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
filled = page.run_js(
|
||
"""
|
||
const code = String(arguments[0] || '').trim();
|
||
if (!code) return 'empty-code';
|
||
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
function setInputValue(input, value) {
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
const tracker = input._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (nativeSetter) nativeSetter.call(input, value);
|
||
else input.value = value;
|
||
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
const aggregate = Array.from(document.querySelectorAll(
|
||
'input[data-input-otp=\"true\"], input[name=\"code\"], input[autocomplete=\"one-time-code\"], input[inputmode=\"numeric\"], input[inputmode=\"text\"]'
|
||
)).find((node) => isVisible(node) && !node.disabled && !node.readOnly && Number(node.maxLength || 6) > 1);
|
||
|
||
if (aggregate) {
|
||
aggregate.focus();
|
||
aggregate.click();
|
||
setInputValue(aggregate, code);
|
||
return String(aggregate.value || '').replace(/\\s+/g, '') ? 'filled-aggregate' : 'aggregate-failed';
|
||
}
|
||
|
||
const otpBoxes = Array.from(document.querySelectorAll('input')).filter((node) => {
|
||
if (!isVisible(node) || node.disabled || node.readOnly) return false;
|
||
const maxLength = Number(node.maxLength || 0);
|
||
const ac = String(node.autocomplete || '').toLowerCase();
|
||
return maxLength === 1 || ac === 'one-time-code';
|
||
});
|
||
|
||
if (otpBoxes.length >= code.length) {
|
||
for (let i = 0; i < code.length; i += 1) {
|
||
const ch = code[i] || '';
|
||
const box = otpBoxes[i];
|
||
box.focus();
|
||
box.click();
|
||
setInputValue(box, ch);
|
||
box.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }));
|
||
box.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }));
|
||
}
|
||
const merged = otpBoxes.slice(0, code.length).map((x) => String(x.value || '').trim()).join('');
|
||
return merged.length ? 'filled-boxes' : 'boxes-failed';
|
||
}
|
||
|
||
return 'not-ready';
|
||
""",
|
||
clean_code,
|
||
)
|
||
|
||
if filled == "not-ready":
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
if "failed" in str(filled):
|
||
if log_callback:
|
||
log_callback(f"[Debug] 验证码填写失败: {filled}")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
|
||
clicked = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
const buttons = Array.from(document.querySelectorAll('button[type=\"submit\"], button')).filter((node) => {
|
||
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
|
||
const btn = buttons.find((node) => {
|
||
const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase();
|
||
return (
|
||
t.includes('确认邮箱') ||
|
||
t.includes('继续') ||
|
||
t.includes('下一步') ||
|
||
t.includes('confirm') ||
|
||
t.includes('continue') ||
|
||
t.includes('next')
|
||
);
|
||
});
|
||
|
||
if (!btn) return 'no-button';
|
||
btn.focus();
|
||
btn.click();
|
||
return 'clicked';
|
||
"""
|
||
)
|
||
|
||
if clicked == "clicked" or clicked == "no-button":
|
||
if log_callback:
|
||
log_callback(f"[Debug] 已填写验证码并提交: {code}")
|
||
sleep_with_cancel(1.5, cancel_callback)
|
||
return code
|
||
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
|
||
raise Exception("验证码已获取,但自动填写/提交失败")
|
||
|
||
|
||
def getTurnstileToken(session, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
if page is None:
|
||
raise Exception("页面未就绪,无法执行 Turnstile")
|
||
|
||
try:
|
||
page.run_js(
|
||
"try { if (window.turnstile && typeof turnstile.reset === 'function') turnstile.reset(); } catch(e) {}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
for _ in range(0, 20):
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
token = page.run_js(
|
||
"""
|
||
try {
|
||
const byInput = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim();
|
||
if (byInput) return byInput;
|
||
if (window.turnstile && typeof turnstile.getResponse === 'function') {
|
||
return String(turnstile.getResponse() || '').trim();
|
||
}
|
||
return '';
|
||
} catch(e) { return ''; }
|
||
"""
|
||
)
|
||
token = str(token or "").strip()
|
||
if len(token) >= 80:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 已通过,token长度={len(token)}")
|
||
return token
|
||
|
||
challenge_input = page.ele("@name=cf-turnstile-response")
|
||
if challenge_input:
|
||
wrapper = challenge_input.parent()
|
||
iframe = None
|
||
try:
|
||
iframe = wrapper.shadow_root.ele("tag:iframe")
|
||
except Exception:
|
||
iframe = None
|
||
if iframe:
|
||
try:
|
||
iframe.run_js(
|
||
"""
|
||
window.dtp = 1;
|
||
function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||
let sx = getRandomInt(800, 1200);
|
||
let sy = getRandomInt(400, 700);
|
||
Object.defineProperty(MouseEvent.prototype, 'screenX', { value: sx });
|
||
Object.defineProperty(MouseEvent.prototype, 'screenY', { value: sy });
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
body_sr = iframe.ele("tag:body").shadow_root
|
||
btn = body_sr.ele("tag:input")
|
||
if btn:
|
||
btn.click()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
# 兜底:尝试触发页面上可见的 Turnstile 容器
|
||
page.run_js(
|
||
"""
|
||
const nodes = Array.from(document.querySelectorAll('div,span,iframe')).filter((n) => {
|
||
const txt = (n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || '');
|
||
return String(txt).toLowerCase().includes('turnstile');
|
||
});
|
||
if (nodes.length && typeof nodes[0].click === 'function') nodes[0].click();
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
sleep_with_cancel(1, cancel_callback)
|
||
|
||
raise Exception("Turnstile 获取 token 失败")
|
||
|
||
|
||
def build_profile():
|
||
given_name_pool = [
|
||
"Neo", "Ethan", "Liam", "Noah", "Lucas", "Mason", "Ryan", "Leo",
|
||
"Owen", "Aiden", "Elio", "Aron", "Ivan", "Nolan", "Evan", "Kai",
|
||
"Caleb", "Adam", "Ezra", "Miles", "Logan", "Carter", "Hunter", "Jason",
|
||
"Brian", "Dylan", "Alex", "Colin", "Blake", "Gavin", "Henry", "Julian",
|
||
"Kevin", "Louis", "Marcus", "Nathan", "Oscar", "Peter", "Quinn", "Robin",
|
||
"Simon", "Tristan", "Victor", "Wesley", "Xavier", "Yuri", "Zane", "Felix",
|
||
"Aaron", "Damian",
|
||
]
|
||
family_name_pool = [
|
||
"Lin", "Wang", "Zhao", "Liu", "Chen", "Zhang", "Xu", "Sun",
|
||
"Guo", "He", "Yang", "Wu", "Zhou", "Tang", "Qin", "Shi",
|
||
"Fang", "Peng", "Cao", "Deng", "Fan", "Fu", "Gao", "Han",
|
||
"Hu", "Jiang", "Kong", "Lu", "Ma", "Nie", "Pan", "Qiao",
|
||
"Ren", "Shao", "Tian", "Xie", "Yan", "Yao", "Yu", "Zeng",
|
||
"Bai", "Duan", "Hou", "Jin", "Kang", "Luo", "Mao", "Song",
|
||
"Wei", "Xiong",
|
||
]
|
||
given_name = random.choice(given_name_pool)
|
||
family_name = random.choice(family_name_pool)
|
||
password = "N" + secrets.token_hex(4) + "!a7#" + secrets.token_urlsafe(6)
|
||
return given_name, family_name, password
|
||
|
||
|
||
def fill_profile_and_submit(session, timeout=120, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
given_name, family_name, password = build_profile()
|
||
deadline = time.time() + timeout
|
||
form_filled_once = False
|
||
wait_cf_since = None
|
||
last_cf_retry_at = 0.0
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if not form_filled_once:
|
||
filled = page.run_js(
|
||
"""
|
||
const givenName = arguments[0];
|
||
const familyName = arguments[1];
|
||
const password = arguments[2];
|
||
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
function pickInput(selector) {
|
||
return Array.from(document.querySelectorAll(selector)).find((node) => {
|
||
return isVisible(node) && !node.disabled && !node.readOnly;
|
||
}) || null;
|
||
}
|
||
|
||
function setInputValue(input, value) {
|
||
if (!input) return false;
|
||
input.focus();
|
||
input.click();
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
const tracker = input._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (nativeSetter) nativeSetter.call(input, value);
|
||
else input.value = value;
|
||
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
input.blur();
|
||
return String(input.value || '').trim() === String(value || '').trim();
|
||
}
|
||
|
||
const givenInput = pickInput('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"], input[aria-label*="名"]');
|
||
const familyInput = pickInput('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"], input[aria-label*="姓"]');
|
||
const passwordInput = pickInput('input[data-testid="password"], input[name="password"], input[type="password"], input[autocomplete="new-password"]');
|
||
|
||
if (!givenInput || !familyInput || !passwordInput) return 'not-ready';
|
||
|
||
const ok1 = setInputValue(givenInput, givenName);
|
||
const ok2 = setInputValue(familyInput, familyName);
|
||
const ok3 = setInputValue(passwordInput, password);
|
||
|
||
if (!ok1 || !ok2 || !ok3) return 'fill-failed';
|
||
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
||
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
const submitBtn = buttons.find((node) => {
|
||
const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase();
|
||
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
||
});
|
||
|
||
// 必须等待 Cloudflare 校验通过后再提交
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
const cfPresent = !!cfInput
|
||
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
||
if (cfPresent) {
|
||
const token = String((cfInput && cfInput.value) || '').trim();
|
||
const solvedByToken = token.length >= 80;
|
||
if (!solvedByToken) return 'wait-cloudflare:' + token.length;
|
||
}
|
||
|
||
if (submitBtn) {
|
||
return 'ready-to-submit';
|
||
}
|
||
return 'filled-no-submit';
|
||
""",
|
||
given_name,
|
||
family_name,
|
||
password,
|
||
)
|
||
|
||
if isinstance(filled, str) and filled.startswith("wait-cloudflare"):
|
||
form_filled_once = True
|
||
if log_callback:
|
||
token_len = filled.split(":", 1)[1] if ":" in filled else "0"
|
||
log_callback(f"[Debug] 资料已填写,等待 Cloudflare 人机验证通过... 当前token长度={token_len}")
|
||
if token_len == "0":
|
||
pause_seconds = random.uniform(1, 3)
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare token 为空,暂停 {pause_seconds:.1f}s 后继续检测")
|
||
sleep_with_cancel(pause_seconds, cancel_callback)
|
||
now = time.time()
|
||
if wait_cf_since is None:
|
||
wait_cf_since = now
|
||
# 卡住后自动二次复用 Turnstile 组件
|
||
if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8:
|
||
if log_callback:
|
||
log_callback("[Debug] Cloudflare 验证卡住,开始二次复用 Turnstile...")
|
||
try:
|
||
token = getTurnstileToken(session, log_callback=log_callback, cancel_callback=cancel_callback)
|
||
if token:
|
||
synced = page.run_js(
|
||
"""
|
||
const token = String(arguments[0] || '').trim();
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
if (!cfInput || !token) return false;
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
if (nativeSetter) nativeSetter.call(cfInput, token);
|
||
else cfInput.value = token;
|
||
cfInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
cfInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return String(cfInput.value || '').trim().length;
|
||
""",
|
||
token,
|
||
)
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 二次复用完成,回填长度={synced}")
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}")
|
||
last_cf_retry_at = now
|
||
sleep_with_cancel(0.8, cancel_callback)
|
||
continue
|
||
|
||
if filled in ("ready-to-submit", "filled-no-submit"):
|
||
form_filled_once = True
|
||
elif filled == "fill-failed" and log_callback:
|
||
log_callback("[Debug] 资料输入失败,重试中...")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
elif filled == "not-ready":
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
|
||
submit_state = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
const cfPresent = !!cfInput
|
||
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
||
if (cfPresent) {
|
||
const token = String((cfInput && cfInput.value) || '').trim();
|
||
const solvedByToken = token.length >= 80;
|
||
if (!solvedByToken) return 'wait-cloudflare:' + token.length;
|
||
}
|
||
|
||
function buttonText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('value'),
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
||
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
const submitBtn = buttons.find((node) => {
|
||
const t = buttonText(node).replace(/\s+/g, '').toLowerCase();
|
||
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
||
});
|
||
if (!submitBtn) {
|
||
const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
|
||
return 'no-submit-button:' + visibleTexts;
|
||
}
|
||
submitBtn.focus();
|
||
submitBtn.click();
|
||
return 'submitted';
|
||
"""
|
||
)
|
||
|
||
if isinstance(submit_state, str) and submit_state.startswith("wait-cloudflare"):
|
||
if log_callback:
|
||
token_len = submit_state.split(":", 1)[1] if ":" in submit_state else "0"
|
||
log_callback(f"[Debug] 等待 Cloudflare 人机验证通过后再提交... 当前token长度={token_len}")
|
||
now = time.time()
|
||
if wait_cf_since is None:
|
||
wait_cf_since = now
|
||
if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8:
|
||
if log_callback:
|
||
log_callback("[Debug] 提交前仍卡住,自动再次复用 Turnstile...")
|
||
try:
|
||
token = getTurnstileToken(session, log_callback=log_callback, cancel_callback=cancel_callback)
|
||
if token:
|
||
synced = page.run_js(
|
||
"""
|
||
const token = String(arguments[0] || '').trim();
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
if (!cfInput || !token) return false;
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
if (nativeSetter) nativeSetter.call(cfInput, token);
|
||
else cfInput.value = token;
|
||
cfInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
cfInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return String(cfInput.value || '').trim().length;
|
||
""",
|
||
token,
|
||
)
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 二次复用完成,回填长度={synced}")
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}")
|
||
last_cf_retry_at = now
|
||
sleep_with_cancel(0.8, cancel_callback)
|
||
continue
|
||
|
||
if submit_state == "submitted":
|
||
if log_callback:
|
||
log_callback(f"[Debug] 已填写注册资料并提交: {given_name} {family_name}")
|
||
return {"given_name": given_name, "family_name": family_name, "password": password}
|
||
wait_cf_since = None
|
||
if isinstance(submit_state, str) and submit_state.startswith("no-submit-button") and log_callback:
|
||
visible_buttons = submit_state.split(":", 1)[1] if ":" in submit_state else ""
|
||
suffix = f" 可见按钮: {visible_buttons}" if visible_buttons else ""
|
||
log_callback(f"[Debug] 未找到提交按钮,继续等待页面稳定...{suffix}")
|
||
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
|
||
raise Exception("最终注册页资料填写失败")
|
||
|
||
|
||
def wait_for_sso_cookie(session, timeout=120, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
deadline = time.time() + timeout
|
||
last_seen_names = set()
|
||
last_submit_retry = 0.0
|
||
last_cf_retry_at = 0.0
|
||
final_no_submit_state = ""
|
||
final_no_submit_since = None
|
||
final_no_submit_timeout = 25
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
session.refresh_page()
|
||
page = session.page
|
||
if page is None:
|
||
sleep_with_cancel(1, cancel_callback)
|
||
continue
|
||
|
||
# 仍停留在“完成注册”页时,若 Cloudflare 已通过,周期性重试点击提交
|
||
now = time.time()
|
||
if now - last_submit_retry >= 2.5:
|
||
retried = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
const titleHit = !!Array.from(document.querySelectorAll('h1,h2,div,span')).find((el) => {
|
||
const t = (el.textContent || '').replace(/\s+/g, '');
|
||
const lower = t.toLowerCase();
|
||
return t.includes('完成注册') || lower.includes('completeyoursignup') || lower.includes('completesignup');
|
||
});
|
||
if (!titleHit) return 'not-final-page';
|
||
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
const cfPresent = !!cfInput
|
||
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
||
if (cfPresent) {
|
||
const token = String((cfInput && cfInput.value) || '').trim();
|
||
const solved = token.length >= 80;
|
||
if (!solved) return 'final-page-wait-cf:' + token.length;
|
||
}
|
||
|
||
function buttonText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('value'),
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
||
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
const submitBtn = buttons.find((node) => {
|
||
const t = buttonText(node).replace(/\s+/g, '').toLowerCase();
|
||
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
||
});
|
||
if (!submitBtn) {
|
||
const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
|
||
return 'final-page-no-submit:' + visibleTexts;
|
||
}
|
||
submitBtn.focus();
|
||
submitBtn.click();
|
||
return 'final-page-clicked-submit';
|
||
"""
|
||
)
|
||
last_submit_retry = now
|
||
if log_callback and (retried == "final-page-clicked-submit" or (isinstance(retried, str) and retried.startswith("final-page-no-submit"))):
|
||
log_callback(f"[Debug] 最终页状态: {retried}")
|
||
if isinstance(retried, str) and retried.startswith("final-page-no-submit"):
|
||
if retried != final_no_submit_state:
|
||
final_no_submit_state = retried
|
||
final_no_submit_since = now
|
||
elif final_no_submit_since and now - final_no_submit_since >= final_no_submit_timeout:
|
||
raise AccountRetryNeeded(
|
||
f"最终注册页状态 {final_no_submit_timeout}s 未变化且未找到提交按钮,重试当前账号: {retried}"
|
||
)
|
||
else:
|
||
final_no_submit_state = ""
|
||
final_no_submit_since = None
|
||
if log_callback and isinstance(retried, str) and retried.startswith("final-page-wait-cf"):
|
||
token_len = retried.split(":", 1)[1] if ":" in retried else "0"
|
||
log_callback(f"[Debug] 最终页状态: final-page-wait-cf, token长度={token_len}")
|
||
if now - last_cf_retry_at >= 10:
|
||
if log_callback:
|
||
log_callback("[Debug] 最终页 Cloudflare 卡住,自动二次复用 Turnstile...")
|
||
try:
|
||
token = getTurnstileToken(session, log_callback=log_callback, cancel_callback=cancel_callback)
|
||
if token:
|
||
synced = page.run_js(
|
||
"""
|
||
const token = String(arguments[0] || '').trim();
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
if (!cfInput || !token) return false;
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
if (nativeSetter) nativeSetter.call(cfInput, token);
|
||
else cfInput.value = token;
|
||
cfInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
cfInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return String(cfInput.value || '').trim().length;
|
||
""",
|
||
token,
|
||
)
|
||
if log_callback:
|
||
log_callback(f"[Debug] 最终页 Turnstile 二次复用完成,回填长度={synced}")
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 最终页 Turnstile 二次复用失败: {cf_exc}")
|
||
last_cf_retry_at = now
|
||
|
||
cookies = page.cookies(all_domains=True, all_info=True) or []
|
||
for item in cookies:
|
||
if isinstance(item, dict):
|
||
name = str(item.get("name", "")).strip()
|
||
value = str(item.get("value", "")).strip()
|
||
else:
|
||
name = str(getattr(item, "name", "")).strip()
|
||
value = str(getattr(item, "value", "")).strip()
|
||
|
||
if name:
|
||
last_seen_names.add(name)
|
||
|
||
if name == "sso" and value:
|
||
if log_callback:
|
||
log_callback("[Debug] 已获取到 sso cookie")
|
||
return value
|
||
except PageDisconnectedError:
|
||
session.refresh_page()
|
||
page = session.page
|
||
except AccountRetryNeeded:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
|
||
sleep_with_cancel(1, cancel_callback)
|
||
|
||
raise Exception(
|
||
f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}"
|
||
)
|
||
|
||
|
||
def cli_log(message):
|
||
text = str(message)
|
||
# [Debug] 细节日志一律不输出,CLI 只保留必要信息
|
||
if "[Debug]" in text:
|
||
return
|
||
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||
print(f"[{timestamp}] {text}", flush=True)
|
||
|
||
|
||
class SharedState:
|
||
"""并发 worker 间共享的状态:名额发放、计数、文件与日志写入,全部加锁。"""
|
||
|
||
def __init__(self, count, accounts_output_file):
|
||
self.count = count
|
||
self.accounts_output_file = accounts_output_file
|
||
self.mail_cred_file = os.path.join(
|
||
os.path.dirname(__file__), "mail_credentials.txt"
|
||
)
|
||
self._issued = 0 # 已发放的名额数(总共发放 count 个)
|
||
self.success_count = 0
|
||
self.fail_count = 0
|
||
self.stop_requested = False
|
||
self._lock = threading.Lock()
|
||
self._io_lock = threading.Lock()
|
||
|
||
def claim_slot(self):
|
||
"""领取一个注册名额,返回 (是否成功, 序号)。"""
|
||
with self._lock:
|
||
if self.stop_requested or self._issued >= self.count:
|
||
return False, 0
|
||
self._issued += 1
|
||
return True, self._issued
|
||
|
||
def record_success(self):
|
||
with self._lock:
|
||
self.success_count += 1
|
||
return self.success_count
|
||
|
||
def record_fail(self):
|
||
with self._lock:
|
||
self.fail_count += 1
|
||
return self.fail_count
|
||
|
||
def should_stop(self):
|
||
return self.stop_requested
|
||
|
||
def stop(self):
|
||
self.stop_requested = True
|
||
|
||
def log(self, worker_id, message):
|
||
with self._io_lock:
|
||
cli_log(f"[W{worker_id}] {message}")
|
||
|
||
def save_account(self, line):
|
||
with self._io_lock:
|
||
try:
|
||
with open(self.accounts_output_file, "a", encoding="utf-8") as f:
|
||
f.write(line)
|
||
except Exception as exc:
|
||
cli_log(f"[Debug] 保存账号文件失败: {exc}")
|
||
|
||
def save_mail_credential(self, email, dev_token):
|
||
with self._io_lock:
|
||
try:
|
||
with open(self.mail_cred_file, "a", encoding="utf-8") as f:
|
||
f.write(f"{email}\t{dev_token}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def register_one(session, shared, worker_id, slot_no):
|
||
"""完成一个账号的完整注册流程(原串行循环体的一次迭代)。"""
|
||
|
||
def log(msg):
|
||
shared.log(worker_id, msg)
|
||
|
||
cancel = shared.should_stop
|
||
email = ""
|
||
dev_token = ""
|
||
code = ""
|
||
mail_ok = False
|
||
max_mail_retry = 3
|
||
for mail_try in range(1, max_mail_retry + 1):
|
||
open_signup_page(session, log_callback=log, cancel_callback=cancel)
|
||
email, dev_token = fill_email_and_submit(
|
||
session, log_callback=log, cancel_callback=cancel
|
||
)
|
||
log(f"[*] 邮箱: {email}")
|
||
shared.save_mail_credential(email, dev_token)
|
||
try:
|
||
code = fill_code_and_submit(
|
||
session, email, dev_token, log_callback=log, cancel_callback=cancel
|
||
)
|
||
mail_ok = True
|
||
break
|
||
except Exception as mail_exc:
|
||
msg = str(mail_exc)
|
||
if ("未收到验证码" in msg or "验证码" in msg) and mail_try < max_mail_retry:
|
||
log(f"[!] 本邮箱未取到验证码,自动更换新邮箱重试: {msg}")
|
||
session.restart()
|
||
sleep_with_cancel(1, cancel)
|
||
continue
|
||
raise
|
||
|
||
if not mail_ok:
|
||
raise Exception("验证码阶段失败,已达到最大重试次数")
|
||
profile = fill_profile_and_submit(session, log_callback=log, cancel_callback=cancel)
|
||
sso = wait_for_sso_cookie(session, log_callback=log, cancel_callback=cancel)
|
||
if config.get("enable_nsfw", True):
|
||
enable_nsfw_for_token(sso, log_callback=log)
|
||
password = profile.get("password", "")
|
||
shared.save_account(f"{email}----{password}----{sso}\n")
|
||
export_cpa_after_register(
|
||
email, password, session=session, sso=sso, log_callback=log
|
||
)
|
||
return email
|
||
|
||
|
||
def register_worker(worker_id, shared):
|
||
"""单个并发 worker:自建独立浏览器,循环领取名额并注册。"""
|
||
|
||
def log(msg):
|
||
shared.log(worker_id, msg)
|
||
|
||
session = BrowserSession(log_callback=log)
|
||
try:
|
||
session.start()
|
||
log("[Debug] 浏览器已启动")
|
||
except Exception as exc:
|
||
log(f"[!] 浏览器启动失败,worker 退出: {exc}")
|
||
return
|
||
|
||
max_slot_retry = 3
|
||
try:
|
||
while not shared.should_stop():
|
||
ok, slot_no = shared.claim_slot()
|
||
if not ok:
|
||
break
|
||
log(f"--- 开始第 {slot_no}/{shared.count} 个账号 ---")
|
||
retry_count_for_slot = 0
|
||
while True:
|
||
try:
|
||
email = register_one(session, shared, worker_id, slot_no)
|
||
total = shared.record_success()
|
||
log(f"[+] 注册成功: {email}(累计成功 {total})")
|
||
break
|
||
except RegistrationCancelled:
|
||
raise
|
||
except AccountRetryNeeded as exc:
|
||
retry_count_for_slot += 1
|
||
if retry_count_for_slot <= max_slot_retry:
|
||
log(
|
||
f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{max_slot_retry} 次: {exc}"
|
||
)
|
||
session.restart()
|
||
sleep_with_cancel(1, shared.should_stop)
|
||
continue
|
||
total = shared.record_fail()
|
||
log(f"[-] 当前账号已达到最大重试次数,跳过: {exc}(累计失败 {total})")
|
||
break
|
||
except Exception as exc:
|
||
total = shared.record_fail()
|
||
log(f"[-] 注册失败: {exc}(累计失败 {total})")
|
||
break
|
||
if shared.should_stop():
|
||
break
|
||
session.restart()
|
||
except RegistrationCancelled:
|
||
log("[!] 注册被停止")
|
||
except Exception as exc:
|
||
log(f"[!] worker 异常退出: {exc}")
|
||
finally:
|
||
session.stop()
|
||
cleanup_mint_browsers()
|
||
log("[Debug] worker 结束")
|
||
|
||
|
||
def run_registration_concurrent(count, concurrency):
|
||
accounts_output_file = os.path.join(
|
||
os.path.dirname(__file__),
|
||
f"accounts_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt",
|
||
)
|
||
concurrency = max(1, min(concurrency, count))
|
||
shared = SharedState(count, accounts_output_file)
|
||
cli_log(f"[*] 开始并发注册,目标 {count} 个,并发 {concurrency} 个浏览器")
|
||
cli_log(f"[*] 成功账号将实时保存到: {accounts_output_file}")
|
||
threads = []
|
||
for worker_id in range(concurrency):
|
||
t = threading.Thread(
|
||
target=register_worker, args=(worker_id, shared), daemon=True
|
||
)
|
||
t.start()
|
||
threads.append(t)
|
||
time.sleep(1) # 错开各 worker 启动,降低同一出口 IP 的瞬时并发压力
|
||
try:
|
||
while any(t.is_alive() for t in threads):
|
||
for t in threads:
|
||
t.join(timeout=0.5)
|
||
except KeyboardInterrupt:
|
||
shared.stop()
|
||
cli_log("[!] 收到 Ctrl+C,正在停止所有 worker 并清理...")
|
||
for t in threads:
|
||
t.join()
|
||
cleanup_stray_chrome()
|
||
cli_log(f"[*] 任务结束。成功 {shared.success_count} | 失败 {shared.fail_count}")
|
||
|
||
|
||
def main():
|
||
load_config()
|
||
count = int(config.get("register_count", 1) or 1)
|
||
cli_log("[*] 已加载配置")
|
||
cli_log(
|
||
f"[*] 当前邮箱服务商: {config.get('email_provider', 'duckmail')} | 目标注册数: {count}"
|
||
)
|
||
if config.get("cpa_export_enabled", True):
|
||
push = "开" if config.get("cpa_push_enabled", False) else "关"
|
||
cli_log(
|
||
f"[*] CPA 导出: 开启 | 目录: {config.get('cpa_auth_dir', './cpa_auths')} "
|
||
f"| 远端推送: {push}"
|
||
+ (f" -> {config.get('cpa_remote_base')}" if config.get("cpa_push_enabled") else "")
|
||
)
|
||
else:
|
||
cli_log("[*] CPA 导出: 关闭(仅写 accounts_*.txt)")
|
||
try:
|
||
raw = input("请输入并发数量(同时开几个浏览器,直接回车=1): ").strip()
|
||
except (KeyboardInterrupt, EOFError):
|
||
cli_log("[!] 已取消")
|
||
return
|
||
try:
|
||
concurrency = int(raw) if raw else 1
|
||
except ValueError:
|
||
cli_log("[!] 并发数量无效,使用 1")
|
||
concurrency = 1
|
||
if concurrency < 1:
|
||
concurrency = 1
|
||
run_registration_concurrent(count, concurrency)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|