Files
grok-keygen-new/grok_register_ttk.py
T
chaos a4dbe948b2 Disable proxy pool by default; add managed proxy lifecycle and Turnstile fixes.
Keep proxy_pool_enabled/managed off for direct registration, while shipping
SSO OAuth CPA export, visible Turnstile click handling, and proxy harvest tooling.
2026-07-13 07:16:11 +08:00

3604 lines
138 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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": "",
# ===== 代理池(注册浏览器 + CPA 铸造共用线程绑定出口)=====
"proxy_pool_enabled": False, # True=启用代理池;False=仅用 proxy 单代理/直连
"proxy_managed": False, # True=只用 proxy_alive.json(accounts.x.ai 测通过的)
"proxy_db": "./proxy_alive.json", # 测通库存
"proxy_pool": [], # 静态列表兜底
"proxy_pool_file": "./proxies.txt", # harvest 会导出;也可手写
"proxy_pool_mode": "weighted", # weighted | round_robin | random
"proxy_rotate_each_account": True, # 每个账号换一个出口(重启浏览器)
"proxy_mail_direct": True, # 邮箱 API 直连,不走代理(temp-email/Worker 更稳)
# 公开源采集 / 巡检(python proxy_manager.py harvest|check|loop)
"proxy_source_pick_k": 3, # 每轮按权重抽几个源
"proxy_harvest_per_source": 150,
"proxy_harvest_total": 400,
"proxy_probe_workers": 40,
"proxy_probe_timeout": 12,
"proxy_max_alive": 200,
"proxy_min_alive": 15,
"proxy_harvest_interval_sec": 600,
"proxy_check_interval_sec": 300,
"proxy_source_disable_after": 3, # 源连续 0 产出/失败 N 次禁用
"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()
try:
import proxy_pool
proxy_pool.configure_from_config(config)
except Exception:
pass
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(for_mail=False):
"""返回 curl_cffi/requests 用的 proxies dict。
for_mail=True 且 proxy_mail_direct 时强制直连(邮箱 API 不需要换 IP)。
否则优先线程绑定代理(代理池),再回退 config.proxy。
"""
try:
import proxy_pool
if for_mail and proxy_pool.mail_direct():
return {}
p = proxy_pool.resolve_for_http()
if p:
return {"http": p, "https": p}
except Exception:
pass
proxy = str(config.get("proxy", "") or "").strip()
if proxy:
return {"http": proxy, "https": proxy}
return {}
def current_proxy_label():
try:
import proxy_pool
p = proxy_pool.get_thread_proxy() or str(config.get("proxy") or "").strip()
return proxy_pool.proxy_log_label(p)
except Exception:
p = str(config.get("proxy") or "").strip()
return p or "(direct)"
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。
铸造代理优先复用当前线程绑定(与注册浏览器同出口),避免直连被 abusive 拦。
"""
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}"}
# 把线程代理 / session 代理写进 mint_proxy,覆盖全局直连
cfg = dict(config)
try:
import proxy_pool
bound = ""
if session is not None:
bound = getattr(session, "proxy", None) or ""
bound = bound or proxy_pool.get_thread_proxy()
if bound:
cfg["mint_proxy"] = bound
cfg["proxy"] = bound
proxy_pool.set_thread_proxy(bound)
log(f"[cpa] mint 代理={proxy_pool.proxy_log_label(bound)}")
else:
log(f"[cpa] mint 代理={current_proxy_label()}")
except Exception:
pass
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=cfg,
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}")
try:
import proxy_pool
proxy_pool.report_success()
except Exception:
pass
elif result.get("skipped"):
log(f"[cpa] 跳过: {result.get('reason')}")
else:
err = str(result.get("error") or result)
log(f"[!] CPA 导出未成功: {err}")
# OAuth 403 abusive / 代理隧道失败 → 当前出口作废
low = err.lower()
if any(k in low for k in ("abusive", "403", "blocked", "proxy", "tunnel", "timeout")):
try:
import proxy_pool
proxy_pool.report_failure(reason=f"cpa mint: {err[:160]}")
except Exception:
pass
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(proxy=None):
"""创建 Chromium 启动参数。proxy 优先用入参,否则用线程绑定/配置。"""
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")
# 固定窗口尺寸,避免 Turnstile iframe 在 0 尺寸窗口里不渲染
options.set_argument("--window-size=1280,900")
# 隐藏窗口(可选):仅移出屏幕。注意:部分环境下会让 Turnstile 永远 token=0
if config.get("hide_window", False):
options.set_argument("--window-position=-32000,-32000")
else:
options.set_argument("--window-position=40,40")
# 代理:线程绑定(代理池)> 入参 > config.proxy
use_proxy = ""
try:
import proxy_pool
if proxy is not None:
use_proxy = proxy_pool.normalize_proxy(proxy)
else:
use_proxy = proxy_pool.get_thread_proxy() or proxy_pool.normalize_proxy(
config.get("proxy") or ""
)
chrome_proxy = proxy_pool.proxy_for_chromium(use_proxy)
if chrome_proxy:
options.set_argument(f"--proxy-server={chrome_proxy}")
if proxy_pool.proxy_has_auth(use_proxy):
# Chromium 不支持 URL 内嵌账号密码;需本地 auth 扩展或无认证代理
pass
except Exception:
raw = (proxy if proxy is not None else config.get("proxy") or "") or ""
raw = str(raw).strip()
if raw:
if "://" not in raw:
raw = f"http://{raw}"
options.set_argument(f"--proxy-server={raw}")
# 加载 turnstilePatch 扩展(伪造自动化鼠标事件指纹,帮过 Turnstile)
if os.path.exists(EXTENSION_PATH):
options.add_extension(EXTENSION_PATH)
return options
def ensure_browser_visible_for_turnstile(session, log_callback=None):
"""Turnstile 卡住时把窗口拉回屏幕并聚焦,避免隐藏窗口导致校验永不通过。"""
log = log_callback or (lambda *_: None)
page = getattr(session, "page", None)
browser = getattr(session, "browser", None)
if page is None and browser is None:
return
try:
target = page or browser
for attr_path in (
("set", "window", "max"),
("set", "window", "show"),
("set", "window", "to_front"),
("set", "window", "normal"),
):
try:
obj = target
for part in attr_path:
obj = getattr(obj, part)
if callable(obj):
obj()
except Exception:
pass
try:
if hasattr(target, "set") and hasattr(target.set, "window"):
try:
target.set.window.location(40, 40)
except Exception:
pass
try:
target.set.window.size(1280, 900)
except Exception:
pass
except Exception:
pass
if page is not None:
try:
page.run_js(
"try{window.focus();window.moveTo(40,40);window.resizeTo(1280,900);}catch(e){}"
)
except Exception:
pass
log("[Debug] 已尝试把浏览器窗口拉回前台(Turnstile)")
except Exception as exc:
log(f"[Debug] ensure_browser_visible_for_turnstile: {exc}")
def _is_mail_api_url(url):
"""判断是否为邮箱服务 API(默认直连,不走注册代理池)。"""
u = str(url or "").lower()
if not u:
return False
markers = (
"duckmail",
"maliapi.215.im",
"/admin/new_address",
"/api/new_address",
"/api/mails",
"/api/mail/",
"/api/domains",
"/api/token",
"mail.kanxue.workers.dev",
"temp-email",
"workers.dev/code",
"grok-xai-mail-codes",
)
return any(m in u for m in markers)
def _build_request_kwargs(url=None, **kwargs):
request_kwargs = dict(kwargs)
proxies = request_kwargs.pop("proxies", None)
if proxies is None:
for_mail = _is_mail_api_url(url)
proxies = get_proxies(for_mail=for_mail)
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(url=url, **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
or "ProxyError" in err
or "CONNECT tunnel failed" in err
):
retry_kwargs = dict(kwargs)
retry_kwargs["proxies"] = {}
return requests.get(url, **_build_request_kwargs(url=url, **retry_kwargs))
raise
def http_post(url, **kwargs):
try:
return requests.post(url, **_build_request_kwargs(url=url, **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
or "ProxyError" in err
or "CONNECT tunnel failed" in err
):
retry_kwargs = dict(kwargs)
retry_kwargs["proxies"] = {}
return requests.post(url, **_build_request_kwargs(url=url, **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, proxy=None):
self.browser = None
self.page = None
self.log_callback = log_callback
self.proxy = proxy # 当前绑定出口;None=启动时从池取
self._pid = None
self._port = None
def _log(self, message):
if self.log_callback:
self.log_callback(message)
def bind_proxy(self, proxy=None, rotate=False):
"""绑定本 session 的出口。rotate=True 时从池取下一枚。"""
try:
import proxy_pool
if rotate or (proxy is None and proxy_pool.is_enabled()):
p = proxy_pool.acquire(force=True)
elif proxy is not None:
p = proxy_pool.set_thread_proxy(proxy)
else:
p = proxy_pool.get_thread_proxy() or proxy_pool.normalize_proxy(
config.get("proxy") or ""
)
proxy_pool.set_thread_proxy(p)
self.proxy = p
return p
except Exception:
p = (proxy if proxy is not None else config.get("proxy") or "") or ""
self.proxy = str(p).strip()
return self.proxy
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, rotate_proxy=False):
# 启动前绑定代理(代理池开启时 rotate 可换出口)
self.bind_proxy(proxy=self.proxy, rotate=rotate_proxy)
try:
import proxy_pool
self._log(f"[Debug] 浏览器代理={proxy_pool.proxy_log_label(self.proxy)}")
except Exception:
self._log(f"[Debug] 浏览器代理={self.proxy or '(direct)'}")
last_exc = None
for attempt in range(1, 5):
try:
# 确保线程代理与 session 一致(create_browser_options 会读线程绑定)
try:
import proxy_pool
proxy_pool.set_thread_proxy(self.proxy)
except Exception:
pass
self.browser = Chromium(create_browser_options(proxy=self.proxy))
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, rotate_proxy=None):
"""重启浏览器。rotate_proxy 默认跟随 proxy_rotate_each_account。"""
if rotate_proxy is None:
try:
import proxy_pool
rotate_proxy = proxy_pool.is_enabled() and proxy_pool.rotate_each_account()
except Exception:
rotate_proxy = False
self.stop()
gc.collect()
if rotate_proxy:
self.proxy = None # 强制 acquire 新出口
return self.start(rotate_proxy=bool(rotate_proxy))
def refresh_page(self):
if self.browser is None:
self.restart(rotate_proxy=False)
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(rotate_proxy=False)
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 _read_turnstile_token(page):
"""读取页面上的 Turnstile token(input / turnstile.getResponse)。"""
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') {
const r = String(turnstile.getResponse() || '').trim();
if (r) return r;
}
// 有时 response 写在多个 hidden input
const all = Array.from(document.querySelectorAll('input[name*="turnstile"], textarea[name*="turnstile"]'));
for (const el of all) {
const v = String(el.value || '').trim();
if (v.length >= 80) return v;
}
return '';
} catch(e) { return ''; }
"""
)
return str(token or "").strip()
except Exception:
return ""
def _inspect_turnstile_state(page):
"""探测 Turnstile 形态:visible checkbox / managed invisible / 是否已有 token。
返回 dict:
present, visible, mode('visible'|'managed'|'none'), iframe_count,
max_w, max_h, token_len, detail
"""
default = {
"present": False,
"visible": False,
"mode": "none",
"iframe_count": 0,
"max_w": 0,
"max_h": 0,
"token_len": 0,
"detail": "",
}
if page is None:
return default
try:
info = page.run_js(
r"""
try {
const token = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim();
const iframes = Array.from(document.querySelectorAll(
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], iframe[title*="Cloudflare"], iframe[title*="Turnstile"]'
));
const widgets = Array.from(document.querySelectorAll(
'div.cf-turnstile, [data-sitekey], [class*="cf-turnstile"], [id*="cf-turnstile"]'
));
let maxW = 0, maxH = 0, visible = false;
const sizes = [];
function noteRect(el, tag) {
if (!el) return;
const r = el.getBoundingClientRect();
const w = Math.round(r.width || 0);
const h = Math.round(r.height || 0);
if (w > maxW) maxW = w;
if (h > maxH) maxH = h;
// 可见 checkbox 通常约 300x65;managed 常为 0x0
if (w >= 40 && h >= 20) visible = true;
sizes.push(tag + ':' + w + 'x' + h);
}
iframes.forEach((f, i) => noteRect(f, 'iframe' + i));
widgets.forEach((w, i) => noteRect(w, 'widget' + i));
// 父容器也看一下(有时 iframe 尺寸写在 wrapper)
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
if (cfInput && cfInput.parentElement) noteRect(cfInput.parentElement, 'cf-parent');
const present = !!(iframes.length || widgets.length || cfInput ||
document.querySelector('script[src*="turnstile"], script[src*="challenges.cloudflare"]'));
let mode = 'none';
if (present) mode = visible ? 'visible' : 'managed';
return {
present: present,
visible: visible,
mode: mode,
iframe_count: iframes.length,
max_w: maxW,
max_h: maxH,
token_len: token.length,
detail: sizes.slice(0, 6).join(',')
};
} catch (e) {
return { present: false, visible: false, mode: 'none', iframe_count: 0, max_w: 0, max_h: 0, token_len: 0, detail: String(e) };
}
"""
)
if not isinstance(info, dict):
return default
out = dict(default)
out.update({
"present": bool(info.get("present")),
"visible": bool(info.get("visible")),
"mode": str(info.get("mode") or "none"),
"iframe_count": int(info.get("iframe_count") or 0),
"max_w": int(info.get("max_w") or 0),
"max_h": int(info.get("max_h") or 0),
"token_len": int(info.get("token_len") or 0),
"detail": str(info.get("detail") or ""),
})
return out
except Exception as exc:
default["detail"] = str(exc)
return default
def _patch_mouse_screen_coords(iframe):
"""给 iframe 注入更像真人的 screenX/screenY,降低 isTrusted 路径被判定为自动化。"""
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);
try {
Object.defineProperty(MouseEvent.prototype, 'screenX', { get: function(){ return sx + (this.clientX||0); } });
Object.defineProperty(MouseEvent.prototype, 'screenY', { get: function(){ return sy + (this.clientY||0); } });
} catch(e) {}
"""
)
except Exception:
pass
def _click_turnstile_checkbox(page, log_callback=None, force=False):
"""尽量真实地点击 Turnstile 复选框(shadow-root / iframe)。
- 可见 checkbox:立刻点
- managed/invisible(0x0):默认不点(force=True 时才轻点兜底)
返回 dict: clicked, visible, mode, detail
"""
log = log_callback or (lambda *_: None)
state = _inspect_turnstile_state(page)
result = {
"clicked": False,
"visible": bool(state.get("visible")),
"mode": str(state.get("mode") or "none"),
"detail": str(state.get("detail") or ""),
}
# managed/invisible:无可见目标时默认不点,避免打断自动校验
if not result["visible"] and not force:
log(
f"[Debug] Turnstile 形态={result['mode']} "
f"size={state.get('max_w')}x{state.get('max_h')},跳过点击(managed)"
)
return result
clicked = False
click_path = ""
# 1) 经典路径:cf-turnstile-response 父节点 shadow_root → iframe → body.shadow_root → input
try:
challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.5)
except Exception:
challenge_input = None
if challenge_input:
try:
wrapper = challenge_input.parent()
iframe = None
try:
iframe = wrapper.shadow_root.ele("tag:iframe")
except Exception:
iframe = None
if iframe is None:
try:
iframe = page.ele(
'css:iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"]',
timeout=0.5,
)
except Exception:
iframe = None
if iframe is not None:
_patch_mouse_screen_coords(iframe)
try:
# 优先点 iframe 本体(可见时更接近真人)
try:
iframe.click()
clicked = True
click_path = "iframe-self"
except Exception:
pass
body = iframe.ele("tag:body", timeout=1)
body_sr = body.shadow_root if body is not None else None
btn = None
if body_sr is not None:
for sel in (
"tag:input",
"css:input[type=checkbox]",
"css:input",
"css:.mark",
"css:label",
"css:#content",
"css:.cb-i",
):
try:
btn = body_sr.ele(sel, timeout=0.3)
except Exception:
btn = None
if btn is not None:
break
if btn is not None:
try:
btn.click()
except Exception:
try:
btn.click(by_js=True)
except Exception:
pass
clicked = True
click_path = click_path or "shadow-input"
log("[Debug] Turnstile shadow checkbox 已点击")
elif body is not None and not clicked:
try:
body.click()
clicked = True
click_path = "iframe-body"
except Exception:
pass
except Exception as exc:
log(f"[Debug] Turnstile shadow 点击失败: {exc}")
except Exception as exc:
log(f"[Debug] Turnstile wrapper 路径失败: {exc}")
# 2) 页面级 iframe 扫描(优先可见尺寸)
if not clicked:
try:
iframes = page.eles(
'css:iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], '
'iframe[title*="Cloudflare"], iframe[title*="Turnstile"]'
) or []
# 先点大尺寸的
ranked = []
for iframe in iframes[:6]:
try:
rect = getattr(iframe, "rect", None)
w = h = 0
if rect is not None:
try:
w = int(getattr(rect, "size", [0, 0])[0] or 0)
h = int(getattr(rect, "size", [0, 0])[1] or 0)
except Exception:
try:
w = int(rect.get("width", 0) or 0)
h = int(rect.get("height", 0) or 0)
except Exception:
w = h = 0
ranked.append((w * h, w, h, iframe))
except Exception:
ranked.append((0, 0, 0, iframe))
ranked.sort(key=lambda x: x[0], reverse=True)
for area, w, h, iframe in ranked[:3]:
if not force and area > 0 and (w < 40 or h < 20):
continue
try:
_patch_mouse_screen_coords(iframe)
try:
iframe.click()
clicked = True
click_path = f"iframe-scan({w}x{h})"
except Exception:
pass
try:
body = iframe.ele("tag:body", timeout=0.5)
if body is not None and getattr(body, "shadow_root", None):
btn = None
for sel in ("tag:input", "css:input", "css:.mark", "css:label"):
try:
btn = body.shadow_root.ele(sel, timeout=0.3)
except Exception:
btn = None
if btn is not None:
break
if btn is not None:
btn.click()
clicked = True
click_path = f"iframe-scan-shadow({w}x{h})"
log("[Debug] Turnstile iframe 扫描点击成功")
break
except Exception:
continue
if clicked:
break
except Exception:
continue
except Exception:
pass
# 3) JS 兜底:只点可见的 turnstile 容器(不点 0x0)
if not clicked:
try:
js_clicked = page.run_js(
"""
function isBig(n){
if(!n) return false;
const r = n.getBoundingClientRect();
return r.width >= 40 && r.height >= 20;
}
const nodes = Array.from(document.querySelectorAll('iframe,div,span,label')).filter((n) => {
const txt = ((n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || '') + ' ' + (n.getAttribute?.('title') || '')).toLowerCase();
const hit = txt.includes('turnstile') || txt.includes('cloudflare') || txt.includes('cf-');
return hit && isBig(n);
});
if (!nodes.length) return false;
const el = nodes[0];
el.scrollIntoView({block:'center', inline:'center'});
if (typeof el.click === 'function') el.click();
return true;
"""
)
if js_clicked:
clicked = True
click_path = "js-visible-container"
log("[Debug] Turnstile 可见容器 JS 点击")
except Exception:
pass
result["clicked"] = bool(clicked)
result["detail"] = click_path or result["detail"]
if clicked:
log(f"[Debug] Turnstile 已点击 path={click_path} mode={result['mode']}")
elif result["visible"]:
log(f"[Debug] Turnstile 可见但点击失败 detail={result['detail']}")
return result
def dismiss_cookie_banner(page, log_callback=None):
"""关闭隐私/Cookie 横幅,避免挡住 Turnstile。"""
log = log_callback or (lambda *_: None)
if page is None:
return False
try:
clicked = page.run_js(
r"""
function isVisible(n){
if(!n) return false;
const s = getComputedStyle(n);
if(s.display==='none' || s.visibility==='hidden' || s.opacity==='0') return false;
const r = n.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
const labels = ['全部允许','全部拒绝','Allow all','Reject all','Accept all','Accept','同意','允许全部'];
const nodes = Array.from(document.querySelectorAll('button,a,[role="button"]'));
for (const lab of labels) {
const el = nodes.find((n) => {
if (!isVisible(n) || n.disabled) return false;
const t = (n.innerText || n.textContent || '').replace(/\s+/g, ' ').trim();
return t === lab || t.includes(lab);
});
if (el) { el.click(); return lab; }
}
return '';
"""
)
if clicked:
log(f"[Debug] 已关闭 Cookie 横幅: {clicked}")
return True
except Exception as exc:
log(f"[Debug] Cookie 横幅处理失败: {exc}")
return False
def getTurnstileToken(session, log_callback=None, cancel_callback=None, timeout=70):
"""等待 Turnstile token。
策略(按用户反馈):
- 可见 checkbox:立刻点,不要干等
- managed/invisible(0x0):先等自动出 token,超时再 force 轻点
"""
page = session.page
if page is None:
raise Exception("页面未就绪,无法执行 Turnstile")
log = log_callback or (lambda *_: None)
ensure_browser_visible_for_turnstile(session, log_callback=log)
dismiss_cookie_banner(page, log_callback=log)
token = _read_turnstile_token(page)
if len(token) >= 80:
log(f"[Debug] Turnstile 已通过,token长度={len(token)}")
return token
deadline = time.time() + max(20, int(timeout))
managed_force_after = 15.0 # managed 模式等 15s 仍无 token 才 force 点
visible_click_interval = 4.0
managed_click_interval = 12.0
last_click = 0.0
last_mode = ""
started = time.time()
attempts = 0
visible_clicks = 0
while time.time() < deadline:
raise_if_cancelled(cancel_callback)
attempts += 1
page = session.page or page
token = _read_turnstile_token(page)
if len(token) >= 80:
log(f"[Debug] Turnstile 已通过,token长度={len(token)}")
return token
state = _inspect_turnstile_state(page)
mode = state.get("mode") or "none"
visible = bool(state.get("visible"))
elapsed = time.time() - started
now = time.time()
if mode != last_mode:
log(
f"[Debug] Turnstile 形态变化: {mode} "
f"size={state.get('max_w')}x{state.get('max_h')} "
f"iframes={state.get('iframe_count')} detail={state.get('detail')}"
)
last_mode = mode
# 一旦从 managed 变成 visible,立刻点,不再等
if visible:
dismiss_cookie_banner(page, log_callback=log)
ensure_browser_visible_for_turnstile(session, log_callback=log)
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
last_click = now
if click_ret.get("clicked"):
visible_clicks += 1
# 可见:高频立刻点(用户反馈「出来了可以点你没点」)
if visible and (last_click == 0.0 or now - last_click >= visible_click_interval):
dismiss_cookie_banner(page, log_callback=log)
ensure_browser_visible_for_turnstile(session, log_callback=log)
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
last_click = now
if click_ret.get("clicked"):
visible_clicks += 1
# managed:先等;超时后才 force 轻点
elif (not visible) and elapsed >= managed_force_after and (
last_click == 0.0 or now - last_click >= managed_click_interval
):
dismiss_cookie_banner(page, log_callback=log)
ensure_browser_visible_for_turnstile(session, log_callback=log)
_click_turnstile_checkbox(page, log_callback=log, force=True)
last_click = now
if attempts % 5 == 0:
log(
f"[Debug] Turnstile 等待中... elapsed={elapsed:.0f}s "
f"mode={mode} visible={visible} clicks={visible_clicks} token_len={len(token)}"
)
sleep_with_cancel(0.6 if visible else 1.0, cancel_callback)
token = _read_turnstile_token(page)
if len(token) >= 80:
return token
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=180, log_callback=None, cancel_callback=None):
"""填写资料并提交。
Turnstile:可见 checkbox 立刻点;managed/invisible 先等,超时再 force 点。
"""
page = session.page
given_name, family_name, password = build_profile()
deadline = time.time() + timeout
form_filled = False
wait_cf_since = None
last_cf_click = 0.0
last_cf_mode = ""
visible_clicks = 0
log = log_callback or (lambda *_: None)
while time.time() < deadline:
raise_if_cancelled(cancel_callback)
page = session.page or page
if not form_filled:
try:
dismiss_cookie_banner(page, log_callback=log)
except Exception:
pass
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 setInputValue(input, value) {
if (!input) return false;
input.focus();
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('input', { bubbles: true, data: value, inputType: 'insertText' }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return String(input.value || '').trim() === String(value || '').trim();
}
function pick(sels) {
for (const s of sels) {
const el = Array.from(document.querySelectorAll(s)).find((n) => isVisible(n) && !n.disabled && !n.readOnly);
if (el) return el;
}
return null;
}
const givenInput = pick(['input[data-testid="givenName"]','input[name="givenName"]','input[autocomplete="given-name"]','input[aria-label*="名"]']);
const familyInput = pick(['input[data-testid="familyName"]','input[name="familyName"]','input[autocomplete="family-name"]','input[aria-label*="姓"]']);
const passwordInput = pick(['input[data-testid="password"]','input[name="password"]','input[type="password"]','input[autocomplete="new-password"]']);
if (!givenInput || !familyInput || !passwordInput) return 'not-ready';
if (!setInputValue(givenInput, givenName) || !setInputValue(familyInput, familyName) || !setInputValue(passwordInput, password)) {
return 'fill-failed';
}
return 'filled';
""",
given_name,
family_name,
password,
)
if filled == "filled":
form_filled = True
log(f"[Debug] 资料已填写: {given_name} {family_name}")
wait_cf_since = time.time()
# 填完立刻探测一次,可见就马上点
try:
ensure_browser_visible_for_turnstile(session, log_callback=log)
state = _inspect_turnstile_state(page)
if state.get("visible"):
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
last_cf_click = time.time()
last_cf_mode = "visible"
if click_ret.get("clicked"):
visible_clicks += 1
log("[Debug] 资料填完后立刻点击可见 Turnstile")
else:
last_cf_mode = state.get("mode") or "managed"
log(
f"[Debug] Turnstile 初始形态={last_cf_mode} "
f"size={state.get('max_w')}x{state.get('max_h')}"
)
except Exception as exc:
log(f"[Debug] 资料填完后 Turnstile 探测失败: {exc}")
elif filled == "fill-failed":
log("[Debug] 资料输入失败,重试中...")
sleep_with_cancel(0.6, cancel_callback)
else:
sleep_with_cancel(0.5, cancel_callback)
continue
# 表单已填:等 token;可见 checkbox 立刻点,managed 先等
token = _read_turnstile_token(page)
if len(token) < 80:
elapsed = time.time() - (wait_cf_since or time.time())
now = time.time()
state = _inspect_turnstile_state(page)
mode = state.get("mode") or "none"
visible = bool(state.get("visible"))
if mode != last_cf_mode:
log(
f"[Debug] Turnstile 形态={mode} size={state.get('max_w')}x{state.get('max_h')} "
f"detail={state.get('detail')}"
)
last_cf_mode = mode
if visible:
try:
dismiss_cookie_banner(page, log_callback=log)
ensure_browser_visible_for_turnstile(session, log_callback=log)
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
last_cf_click = now
if click_ret.get("clicked"):
visible_clicks += 1
log("[Debug] 检测到可见 Turnstile,立即点击")
except Exception as exc:
log(f"[Debug] 可见 Turnstile 点击失败: {exc}")
# 可见:每 3~4s 点一次,直到出 token
if visible and (last_cf_click == 0.0 or now - last_cf_click >= 3.5):
try:
dismiss_cookie_banner(page, log_callback=log)
ensure_browser_visible_for_turnstile(session, log_callback=log)
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
last_cf_click = now
if click_ret.get("clicked"):
visible_clicks += 1
except Exception:
pass
# managed:15s 后仍无 token 才 force 轻点
elif (not visible) and elapsed >= 15 and (last_cf_click == 0.0 or now - last_cf_click >= 12):
try:
dismiss_cookie_banner(page, log_callback=log)
ensure_browser_visible_for_turnstile(session, log_callback=log)
_click_turnstile_checkbox(page, log_callback=log, force=True)
last_cf_click = now
log(f"[Debug] managed 超时 force 点击 elapsed={elapsed:.0f}s")
except Exception:
pass
if int(elapsed) % 5 == 0:
log(
f"[Debug] 等待 Cloudflare token... elapsed={elapsed:.0f}s "
f"mode={mode} visible={visible} clicks={visible_clicks} len={len(token)}"
)
sleep_with_cancel(0.6 if visible else 1.0, cancel_callback)
continue
log(f"[Debug] Turnstile token 就绪 len={len(token)},提交注册")
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;
}
function buttonText(node) {
return [node.innerText, node.textContent, node.getAttribute('value'), node.getAttribute('aria-label')]
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
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 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) {
return 'no-submit-button:' + buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
}
submitBtn.focus();
submitBtn.click();
return 'submitted';
"""
)
if submit_state == "submitted":
log(f"[Debug] 已填写注册资料并提交: {given_name} {family_name}")
return {"given_name": given_name, "family_name": family_name, "password": password}
if isinstance(submit_state, str) and submit_state.startswith("no-submit-button"):
log(f"[Debug] 未找到提交按钮: {submit_state}")
sleep_with_cancel(0.8, 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}")
# 验证码失败通常不是 IP 问题,先同出口重开;仍失败会走外层 rotate
session.restart(rotate_proxy=False)
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)
# 每个 worker 启动时从池取一枚出口
session = BrowserSession(log_callback=log)
try:
session.start(rotate_proxy=True)
log(f"[Debug] 浏览器已启动 proxy={current_proxy_label()}")
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} 个账号 "
f"proxy={current_proxy_label()} ---"
)
retry_count_for_slot = 0
while True:
try:
# 保持线程代理与 session 一致(HTTP/mint 都读线程绑定)
try:
import proxy_pool
proxy_pool.set_thread_proxy(session.proxy)
except Exception:
pass
email = register_one(session, shared, worker_id, slot_no)
total = shared.record_success()
try:
import proxy_pool
proxy_pool.report_success(session.proxy)
except Exception:
pass
log(f"[+] 注册成功: {email}(累计成功 {total})")
break
except RegistrationCancelled:
raise
except AccountRetryNeeded as exc:
retry_count_for_slot += 1
# 卡住:当前出口作废,换代理
try:
import proxy_pool
proxy_pool.report_failure(session.proxy, reason=str(exc))
except Exception:
pass
if retry_count_for_slot <= max_slot_retry:
log(
f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{max_slot_retry} 次: {exc}"
)
session.restart(rotate_proxy=True)
sleep_with_cancel(1, shared.should_stop)
continue
total = shared.record_fail()
log(f"[-] 当前账号已达到最大重试次数,跳过: {exc}(累计失败 {total})")
break
except Exception as exc:
# 注册页打不开 / 被 x.ai 屏蔽 / 代理隧道失败 → 删库
try:
import proxy_pool
from proxy_manager import is_xai_block_reason
reason = str(exc)
if is_xai_block_reason(reason) or any(
k in reason
for k in (
"未找到「使用邮箱注册」",
"连接已断开",
"Turnstile",
"资料填写失败",
"HTTP Error",
"abusive",
"403",
"Proxy",
"proxy",
"ERR_",
)
):
proxy_pool.report_failure(session.proxy, reason=reason)
except Exception:
pass
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()
try:
import proxy_pool
proxy_pool.clear_thread_proxy()
except Exception:
pass
log("[Debug] worker 结束")
def run_registration_concurrent(count, concurrency):
# 重新装载代理池(防止外部改过 config)
try:
import proxy_pool
proxy_pool.configure_from_config(config)
cli_log(f"[*] 代理: {proxy_pool.status_line()}")
except Exception as exc:
cli_log(f"[*] 代理: 装载失败 {exc},走直连/单 proxy")
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}"
)
try:
import proxy_pool
proxy_pool.configure_from_config(config)
cli_log(f"[*] 代理: {proxy_pool.status_line()}")
except Exception:
cli_log(f"[*] 代理: {current_proxy_label()}")
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()