Files
grok-free-register-oss/grok_register/register.py
chaos d10009d639 Initial commit: grok-free-register-oss
Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
2026-07-16 21:04:05 +08:00

2309 lines
90 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.

"""
Grok Free Register — CSP 异步并发架构
=============================================
单进程 asyncio + 单共享 CloakBrowser + Semaphore 背压:
- S_Worker: 生成 Turnstile token (T)
- P_Worker: 创建邮箱 + 发送验证码 + 轮询验证码 (Q)
- C_Worker: claim pair 并执行注册
- Semaphore 背压控制容量,无需中心调度器
两种邮箱模式(EMAIL_MODE):
- tempmail (默认,零配置): 免费临时邮箱,多 provider 自动 fallback
- custom: 自建域名邮箱,Cloudflare Email Routing → Worker → 本地 webhook
(见 grok_register/email_server.py / cloudflare/email-worker.js)
配置全部走环境变量 / .env(见 .env.example);CLI: --max-mem 6G --target 100
用法:
bash start.sh # 一键引导
"""
import os, json, random, string, time, re, secrets, base64, struct, asyncio, glob, sys, multiprocessing
from contextlib import suppress
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
import requests as req
from urllib.parse import quote
from playwright.async_api import async_playwright
from concurrent.futures import ThreadPoolExecutor
# CSP 架构组件
from grok_register.core.admission import AdmissionGate
from grok_register.core.envelope import ResourceEnvelope
from grok_register.core.inventory import Inventory
from grok_register.core.observer import Metrics
from grok_register.clearance import (
apply_clearance_to_context,
cached_user_agent,
format_prewarm_log,
playwright_proxy_settings,
prewarm_clearance,
register_proxy_url,
)
os.makedirs("keys", exist_ok=True)
SITE_URL = "https://accounts.x.ai"
# ── 配置(环境变量 / .env见 .env.example──
def _env_int(key, default):
try:
return int(str(os.environ.get(key, "")).strip() or default)
except ValueError:
return default
def _env_int_or_none(key):
raw = str(os.environ.get(key, "")).strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
return None
EMAIL_MODE = (os.environ.get("EMAIL_MODE") or "tempmail").strip().lower() # tempmail | custom
if EMAIL_MODE == "mailtm": # 兼容旧名
EMAIL_MODE = "tempmail"
LOCAL_EMAIL_API = (os.environ.get("EMAIL_API") or "http://127.0.0.1:8080").strip()
EMAIL_DOMAIN = (os.environ.get("EMAIL_DOMAIN") or "").strip()
MIN_FREE_MEM_MB = _env_int("MIN_FREE_MEM_MB", 500) # 自动容量派生时保留的内存(MB)
T_TARGET = _env_int("T_TARGET", 4) # token 池缓冲目标
Q_TARGET = _env_int("Q_TARGET", 4) # 就绪验证码缓冲目标
TARGET = _env_int("TARGET", 0) # 攒够 N 个号自动停(0=不限;--target N 可覆盖)
# CSP 容量参数
PHYSICAL_CAP = _env_int("PHYSICAL_CAP", 0) # 本地物理资源许可,0=自动派生
PHYSICAL_PER_CPU = _env_int("PHYSICAL_PER_CPU", 2) # 自动派生 CPU 侧保守上限;压测可临时覆盖
PHYSICAL_MEM_MB = _env_int("PHYSICAL_MEM_MB", 512) # 每个物理许可的保守内存预算(MB)
CAPACITY_PROFILE = (os.environ.get("CAPACITY_PROFILE") or "").strip()
T_SLOT_CAP = _env_int("T_SLOT_CAP", 8) # token 库存缓冲
Q_SLOT_CAP = _env_int("Q_SLOT_CAP", 8) # 验证码库存缓冲
Q_PENDING_CAP = _env_int("Q_PENDING_CAP", 12) # 外部在途 Q 请求上限
T_MAX_AGE = _env_int("T_MAX_AGE", 300) # token 最大年龄(秒)
Q_MAX_AGE = _env_int("Q_MAX_AGE", 120) # 验证码最大年龄(秒)
P_REQUEST_TIMEOUT = _env_int("P_REQUEST_TIMEOUT", 95) # P 等待 Q 返回超时(秒)
C_CONSUME_TIMEOUT = _env_int("C_CONSUME_TIMEOUT", 60) # C 消费完整 pair 超时(秒)
S_WORKERS = _env_int("S_WORKERS", 0) # 0=自动
P_WORKERS = _env_int("P_WORKERS", 0)
C_WORKERS = _env_int("C_WORKERS", 0)
C_HOT_PAGE_POOL = (os.environ.get("C_HOT_PAGE_POOL", "0").strip().lower() in ("1", "true", "yes"))
C_HOT_PAGE_POOL_SIZE = _env_int("C_HOT_PAGE_POOL_SIZE", 0)
C_SET_COOKIE_VIA_REQUEST = (
os.environ.get("C_SET_COOKIE_VIA_REQUEST", "1" if C_HOT_PAGE_POOL else "0")
.strip()
.lower()
in ("1", "true", "yes")
)
# CSP v2 局部门控/批量发送参数。水位默认在启动期结合 Physical_Sem 派生。
_T_HIGH_WATER_OVERRIDE = _env_int_or_none("T_HIGH_WATER")
_T_LOW_WATER_OVERRIDE = _env_int_or_none("T_LOW_WATER")
_Q_HIGH_WATER_OVERRIDE = _env_int_or_none("Q_HIGH_WATER")
_Q_LOW_WATER_OVERRIDE = _env_int_or_none("Q_LOW_WATER")
P_BATCH_MAX = max(1, _env_int("P_BATCH_MAX", 4))
P_SEND_CAP = _env_int("P_SEND_CAP", 0) # >0=显式限制并发 P 发送页面;0=不额外建模
PAGE_GOTO_WAIT_UNTIL = os.environ.get("PAGE_GOTO_WAIT_UNTIL", "domcontentloaded").strip() or "domcontentloaded"
PAGE_POST_WAIT_MS = _env_int("PAGE_POST_WAIT_MS", 500)
PAGE_BLOCK_STATIC_ASSETS = (
os.environ.get("PAGE_BLOCK_STATIC_ASSETS", "0").strip().lower()
in ("1", "true", "yes")
)
REGISTRATION_DIAGNOSTICS = (
os.environ.get("REGISTRATION_DIAGNOSTICS", "0").strip().lower()
in ("1", "true", "yes")
)
REGISTRATION_RATE_LIMIT_COOLDOWN = max(
60, _env_int("REGISTRATION_RATE_LIMIT_COOLDOWN", 60)
)
REGISTRATION_RATE_LIMIT_RECOVERY_SECONDS = max(
1, _env_int("REGISTRATION_RATE_LIMIT_RECOVERY_SECONDS", 60)
)
REGISTRATION_RATE_LIMIT_RECOVERY_INTERVAL = max(
1, _env_int("REGISTRATION_RATE_LIMIT_RECOVERY_INTERVAL", 3)
)
REGISTER_LOG_MODE = "user"
SITE_KEY = None
ACTION_ID = None
STATE_TREE = None
start_time = time.time()
success_count = 0
file_lock = asyncio.Lock()
STOP = asyncio.Event()
# 角色标识 + 轮询/HTTP 专用线程池(与 CPU 密集的浏览器操作解耦)
SOLVE, PRODUCE, CONSUME, IDLE = 'SOLVE', 'PRODUCE', 'CONSUME', 'IDLE'
POLL_EXECUTOR = ThreadPoolExecutor(max_workers=32)
def resolve_register_log_mode(argv=None, env=None):
argv = list([] if argv is None else argv)
env = dict(os.environ if env is None else env)
mode = (env.get("REGISTER_LOG_MODE") or "user").strip().lower()
if "--debug" in argv:
mode = "debug"
if mode not in {"user", "debug"}:
raise ValueError("REGISTER_LOG_MODE must be user or debug")
return mode
def _terminal_output(msg):
print(msg, flush=True)
def log(msg):
try:
_terminal_output(msg)
except Exception:
return
def debug_log(msg):
if REGISTER_LOG_MODE == "debug":
log(msg)
def rand_str(n=15): return ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
def sanitize_terminal_error(error):
return type(error).__name__
def format_user_registration_event(
kind,
*,
task_id=None,
count=None,
rate_per_minute=None,
wait_seconds=None,
remaining=None,
):
if kind == "service_started":
progress = f"剩余 {remaining}" if remaining is not None else "持续运行"
return f"[✓] 注册服务已启动 | {progress}"
if kind == "started":
suffix = f" | 剩余 {remaining}" if remaining is not None else ""
return f"[→] 开始注册 #{task_id}{suffix}"
if kind == "success":
rate = "" if rate_per_minute is None else f"{rate_per_minute:.1f}/分"
return f"[✓] 注册成功 #{task_id} | 运行平均 {rate} | 累计 {count}"
if kind == "failed":
return f"[✗] 注册失败 #{task_id} | 已跳过,继续下一任务"
if kind == "rate_limited":
return f"[⏸] 触发限流 | {wait_seconds}秒后恢复探测"
if kind == "recovered":
return f"[▶] 限流解除 | 实际等待 {wait_seconds}"
if kind == "stopped":
return f"[■] 注册服务已停止 | 累计 {count or 0}"
raise ValueError(f"unknown user registration event: {kind}")
class RegistrationRateLimited(RuntimeError):
"""注册提交被目标站点的限流页替代。"""
class RegistrationRateLimitCircuit:
"""在检测到注册限流后暂停新的 C 阶段提交。"""
def __init__(
self,
cooldown_seconds,
recovery_seconds=60,
recovery_interval=3,
clock=time.monotonic,
):
self.cooldown_seconds = cooldown_seconds
self.recovery_seconds = recovery_seconds
self.recovery_interval = recovery_interval
self._clock = clock
self._blocked_until = 0.0
self._tripped_at = None
self._probe_active = False
self._probe_token = None
self._recovering_until = 0.0
self._next_recovery_submit = 0.0
def remaining_seconds(self):
return max(0, int(self._blocked_until - self._clock() + 0.999))
def is_open(self):
return self.remaining_seconds() > 0
def trip(self):
starts_new_window = not self.is_open()
if self._tripped_at is None:
self._tripped_at = self._clock()
self._recovering_until = 0.0
self._next_recovery_submit = 0.0
self._probe_active = False
self._probe_token = None
self._blocked_until = max(
self._blocked_until,
self._clock() + self.cooldown_seconds,
)
return starts_new_window
async def wait(self):
while True:
if self.is_open():
await asyncio.sleep(min(self.remaining_seconds(), 5))
continue
if self._tripped_at is None:
if not self._recovering_until:
return False
if self._probe_active:
await asyncio.sleep(0.2)
continue
if self._clock() >= self._recovering_until:
self._recovering_until = 0.0
self._next_recovery_submit = 0.0
return False
recovery_wait = self._next_recovery_submit - self._clock()
if recovery_wait > 0:
await asyncio.sleep(min(recovery_wait, 0.5))
continue
if not self._probe_active:
self._probe_active = True
self._probe_token = object()
return self._probe_token
await asyncio.sleep(0.2)
def can_submit(self, probe_token=False):
"""仅允许正常态任务或当前恢复探针发起注册提交。"""
if self._tripped_at is None and not self._recovering_until:
return True
return (
probe_token is not False
and probe_token is self._probe_token
and not self.is_open()
)
def consume_recovery_seconds(self, probe_token=None):
if self._tripped_at is None:
self.complete_recovery_submission(probe_token)
return None
if probe_token is not None and probe_token is not self._probe_token:
return None
elapsed = self._clock() - self._tripped_at
self._tripped_at = None
self._blocked_until = 0.0
self._probe_active = False
self._probe_token = None
self._recovering_until = self._clock() + self.recovery_seconds
self._next_recovery_submit = self._clock() + self.recovery_interval
return elapsed
def complete_recovery_submission(self, probe_token):
"""完成恢复期内的一次成功提交,并按固定节奏放行下一项。"""
if probe_token is not self._probe_token or not self._recovering_until:
return False
self._probe_active = False
self._probe_token = None
self._next_recovery_submit = self._clock() + self.recovery_interval
return True
def release_probe(self, probe_token):
"""提交前资源已失效时让出探针,不额外增加冷却窗口。"""
if probe_token is self._probe_token:
self._probe_active = False
self._probe_token = None
def defer_probe(self, probe_token):
"""真正的探针失败后重新进入完整冷却。"""
if probe_token is not self._probe_token:
return
self._probe_active = False
self._probe_token = None
if self._tripped_at is not None:
self._blocked_until = max(
self._blocked_until,
self._clock() + self.cooldown_seconds,
)
elif self._recovering_until:
self._next_recovery_submit = self._clock() + self.recovery_interval
REGISTRATION_RATE_LIMIT_CIRCUIT = RegistrationRateLimitCircuit(
REGISTRATION_RATE_LIMIT_COOLDOWN,
recovery_seconds=REGISTRATION_RATE_LIMIT_RECOVERY_SECONDS,
recovery_interval=REGISTRATION_RATE_LIMIT_RECOVERY_INTERVAL,
)
def _signup_response_markers(text):
"""将失败响应归类为固定标签,诊断时不输出任何服务端正文。"""
lowered = text.lower()
groups = {
"challenge": ("captcha", "cf-chl", "challenge-platform"),
"rate_limited": ("rate limit", "too many requests", "try again later"),
"signin_page": ("sign in", "log in"),
"signup_page": ("sign up", "create your account"),
"next_page": ("__next", "/_next/"),
"action_error": ("server action", "next-action", "digest"),
}
return ",".join(name for name, needles in groups.items() if any(x in lowered for x in needles)) or "unclassified"
def pb_varint(n):
parts = []
while n > 0x7f: parts.append((n & 0x7f) | 0x80); n >>= 7
parts.append(n); return bytes(parts)
def pb_str(fid, val):
vb = val.encode()
return struct.pack('B', (fid << 3) | 2) + pb_varint(len(vb)) + vb
def decode_jwt_payload(token):
parts = token.split('.')
if len(parts) < 2: return None
payload = parts[1] + '=' * (4 - len(parts[1]) % 4)
try: return json.loads(base64.urlsafe_b64decode(payload))
except Exception: return None
def find_chrome():
paths = glob.glob(os.path.expanduser("~/.cloakbrowser/chromium-*/chrome"))
if not paths: raise RuntimeError("CloakBrowser not found")
return sorted(paths)[-1]
def _read_cmdline(pid: int) -> str:
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
return fh.read().replace(b"\x00", b" ").decode("utf-8", "replace")
except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
return ""
def _is_cloakbrowser_chrome(cmdline: str) -> bool:
"""注册机用的 CloakBrowser 路径;排除 docker FS 的 /usr/lib/chromium。"""
if not cmdline:
return False
if "cloakbrowser" in cmdline:
return True
# playwright 偶发只带 chrome 绝对路径
if "/.cloakbrowser/" in cmdline and "chrome" in cmdline:
return True
return False
def reap_orphan_register_browsers(*, exclude_pids: set[int] | None = None) -> int:
"""杀掉无主的 CloakBrowser/chrome上次 Ctrl-C 失败或 kill -9 残留)。
只动 host 上 cloakbrowser 路径的进程,不动 docker 里 FlareSolverr 的 chromium。
"""
import signal as _signal
import time as _time
exclude = set(exclude_pids or ())
exclude.add(os.getpid())
# 当前进程树:子进程先别杀,留给 browser.close
try:
for line in os.popen(f"ps -o pid= --ppid {os.getpid()}").read().split():
try:
exclude.add(int(line))
except ValueError:
pass
except Exception:
pass
victims: list[int] = []
try:
for name in os.listdir("/proc"):
if not name.isdigit():
continue
pid = int(name)
if pid in exclude or pid <= 1:
continue
cmd = _read_cmdline(pid)
if not _is_cloakbrowser_chrome(cmd):
continue
# 跳过 docker cgroup双保险
try:
with open(f"/proc/{pid}/cgroup", "r", encoding="utf-8") as fh:
if "docker" in fh.read():
continue
except Exception:
pass
victims.append(pid)
except Exception:
return 0
if not victims:
return 0
for pid in victims:
try:
os.kill(pid, _signal.SIGTERM)
except ProcessLookupError:
pass
except PermissionError:
pass
_time.sleep(0.4)
for pid in victims:
try:
os.kill(pid, 0)
except ProcessLookupError:
continue
try:
os.kill(pid, _signal.SIGKILL)
except ProcessLookupError:
pass
except PermissionError:
pass
return len(victims)
async def _close_browser_hard(browser, timeout: float = 5.0) -> None:
"""先正常 close超时则按进程树 SIGKILL避免重启叠浏览器。"""
if browser is None:
return
proc = getattr(browser, "process", None)
pid = None
if proc is not None:
pid = getattr(proc, "pid", None)
try:
await asyncio.wait_for(browser.close(), timeout=timeout)
return
except Exception:
pass
if pid:
try:
# 杀进程组playwright 常把 chrome 放同组)
os.killpg(os.getpgid(pid), 9)
except Exception:
try:
os.kill(pid, 9)
except Exception:
pass
# 扫一遍残留 cloakbrowser
await asyncio.to_thread(reap_orphan_register_browsers)
# ──────────────────────────────────────────────
# 资源检测
# ──────────────────────────────────────────────
def get_system_resources(max_mem_arg=None):
import subprocess
cpu_count = multiprocessing.cpu_count()
try:
out = subprocess.check_output(["free", "-m"]).decode()
for line in out.split("\n"):
if "Mem" in line:
parts = line.split()
total, available = int(parts[1]), int(parts[6])
break
else:
total, available = 4096, 2048
except Exception:
total, available = 4096, 2048
if max_mem_arg:
if max_mem_arg.endswith('%'):
max_mem = int(total * float(max_mem_arg[:-1]) / 100)
elif max_mem_arg.upper().endswith('G'):
max_mem = int(float(max_mem_arg[:-1]) * 1024)
elif max_mem_arg.upper().endswith('M'):
max_mem = int(max_mem_arg[:-1])
else:
max_mem = int(max_mem_arg)
else:
max_mem = available
return {'cpu': cpu_count, 'total_mem': total, 'available_mem': available, 'max_mem': max_mem}
def load_capacity_profile(path=CAPACITY_PROFILE):
"""读取离线校准生成的设备 profile。不存在或无效时返回空配置。"""
if not path:
return {}
try:
with open(path, "r") as f:
data = json.load(f)
except Exception:
return {}
if not isinstance(data, dict):
return {}
profile = {}
try:
physical_cap = int(data.get("physical_cap", 0))
except (TypeError, ValueError):
physical_cap = 0
if physical_cap > 0:
profile["physical_cap"] = physical_cap
return profile
def derive_capacity(
cpu_count,
max_mem_mb,
*,
physical_cap=None,
profile_physical_cap=None,
physical_per_cpu=None,
physical_mem_mb=None,
min_free_mem_mb=None,
):
"""启动期静态容量派生:显式配置 > 设备 profile > CPU/内存保守自动值。"""
configured_physical = PHYSICAL_CAP if physical_cap is None else physical_cap
profiled_physical = profile_physical_cap or 0
per_cpu = PHYSICAL_PER_CPU if physical_per_cpu is None else physical_per_cpu
mem_per_physical = PHYSICAL_MEM_MB if physical_mem_mb is None else physical_mem_mb
reserve_mem = MIN_FREE_MEM_MB if min_free_mem_mb is None else min_free_mem_mb
cpu_cap = max(1, cpu_count * max(1, per_cpu))
usable_mem = max(0, max_mem_mb - reserve_mem)
mem_cap = max(1, usable_mem // max(1, mem_per_physical))
auto_cap = max(1, min(cpu_cap, mem_cap))
if configured_physical > 0:
physical = configured_physical
elif profiled_physical > 0:
physical = max(1, min(profiled_physical, mem_cap))
else:
physical = auto_cap
s_workers = S_WORKERS if S_WORKERS > 0 else physical + 2
p_workers = P_WORKERS if P_WORKERS > 0 else Q_PENDING_CAP + 2
c_workers = C_WORKERS if C_WORKERS > 0 else physical + 2
return physical, s_workers, p_workers, c_workers
def derive_admission_watermarks(
physical_cap,
*,
t_slot_cap=None,
q_pending_cap=None,
t_target=None,
q_target=None,
t_high_override=None,
t_low_override=None,
q_high_override=None,
q_low_override=None,
):
"""派生 CSP v2 局部门控水位。
T 的默认高水位跟随 Physical_Sem,避免物理并发提高后仍只允许少量 T
in-progress。显式环境变量覆盖仍然优先。
"""
t_slot = T_SLOT_CAP if t_slot_cap is None else t_slot_cap
q_pending = Q_PENDING_CAP if q_pending_cap is None else q_pending_cap
t_goal = T_TARGET if t_target is None else t_target
q_goal = Q_TARGET if q_target is None else q_target
t_high_cfg = _T_HIGH_WATER_OVERRIDE if t_high_override is None else t_high_override
t_low_cfg = _T_LOW_WATER_OVERRIDE if t_low_override is None else t_low_override
q_high_cfg = _Q_HIGH_WATER_OVERRIDE if q_high_override is None else q_high_override
q_low_cfg = _Q_LOW_WATER_OVERRIDE if q_low_override is None else q_low_override
if t_high_cfg is None:
t_high = min(max(1, t_slot), max(1, t_goal, physical_cap))
else:
t_high = max(1, min(max(1, t_slot), t_high_cfg))
if t_low_cfg is None:
t_low = max(0, min(t_high, t_high // 2))
else:
t_low = max(0, min(t_high, t_low_cfg))
if q_high_cfg is None:
q_high = max(1, q_pending)
else:
q_high = max(1, min(max(1, q_pending), q_high_cfg))
if q_low_cfg is None:
q_low = max(0, min(q_high, q_goal, q_high // 2))
else:
q_low = max(0, min(q_high, q_low_cfg))
return {
"t_low": t_low,
"t_high": t_high,
"q_low": q_low,
"q_high": q_high,
}
def derive_c_hot_page_pool_size(physical_cap, c_workers, configured_size=None):
"""启动期静态派生 C 热页池容量;显式配置优先。"""
configured = C_HOT_PAGE_POOL_SIZE if configured_size is None else configured_size
if configured and configured > 0:
return configured
return max(1, min(max(1, physical_cap), max(1, c_workers)))
# ──────────────────────────────────────────────
# 配置获取
# ──────────────────────────────────────────────
def _launch_options():
"""Playwright chromium.launch kwargs. HTTP_PROXY/REGISTER_PROXY apply here (not to FlareSolverr)."""
options = {"executable_path": find_chrome(), "headless": True}
proxy = playwright_proxy_settings()
if proxy:
options["proxy"] = proxy
return options
async def _prepare_browser_page(browser):
"""new_page + inject prewarmed CF cookies into its context when available."""
page = await browser.new_page()
await apply_clearance_to_context(page.context)
return page
async def _prepare_browser_context(browser):
"""new_context with optional FS user-agent + CF cookies."""
kwargs = {}
ua = cached_user_agent()
if ua:
kwargs["user_agent"] = ua
context = await browser.new_context(**kwargs)
await apply_clearance_to_context(context)
return context
async def fetch_config():
global SITE_KEY, ACTION_ID, STATE_TREE
debug_log('[*] Fetching config...')
async with async_playwright() as pw:
browser = await pw.chromium.launch(**_launch_options())
try:
page = await _prepare_browser_page(browser)
await page.goto(f'{SITE_URL}/sign-up?redirect=grok-com', timeout=30000)
await page.wait_for_timeout(5000)
html = await page.content()
m = re.search(r'0x4AAAAAAA[a-zA-Z0-9_-]+', html)
if m: SITE_KEY = m.group(0); debug_log(f'[+] SITE_KEY: {SITE_KEY}')
for chunk in re.findall(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', html, re.DOTALL):
if 'sign-up' not in chunk: continue
decoded = chunk.replace('\\"', '"')
f_match = re.search(r'"f":\[\[\[', decoded)
if not f_match: continue
f_start = f_match.start() + 5
end_idx = decoded.find('"$undefined"', f_start)
if end_idx < 0: continue
STATE_TREE = quote(decoded[f_start:end_idx].replace('\\\\"', '"').replace('\\', ''), safe='')
debug_log(f'[+] STATE_TREE: {STATE_TREE[:50]}...')
break
js_urls = re.findall(r'src="(/_next/static/[^"]+\.js)"', html)
for js_url in js_urls[:50]:
try:
js = await page.evaluate(f"(async()=>{{return await fetch('{js_url}').then(r=>r.text()).catch(()=>\"\" )}})()")
if not js: continue
if not any(kw in js for kw in ['createUser','registerUser','emailValidation']): continue
hexes = re.findall(r'[a-fA-F0-9]{40,50}', js)
if hexes: ACTION_ID = hexes[0]; break
except asyncio.CancelledError:
raise
except Exception:
continue
if ACTION_ID: debug_log(f'[+] ACTION_ID: {ACTION_ID}')
finally:
await _close_browser_hard(browser, timeout=5.0)
if not all([SITE_KEY, ACTION_ID, STATE_TREE]):
raise RuntimeError("Config fetch failed")
# ──────────────────────────────────────────────
# 异步操作
# ──────────────────────────────────────────────
async def grpc_create_code(page, email):
inner = pb_str(1, email)
frame = b'\x00' + struct.pack('>I', len(inner)) + inner
fb64 = base64.b64encode(frame).decode()
s = await page.evaluate(f"(async()=>{{var fb=Uint8Array.from(atob('{fb64}'),c=>c.charCodeAt(0));var r=await fetch('{SITE_URL}/auth_mgmt.AuthManagement/CreateEmailValidationCode',{{method:'POST',headers:{{'content-type':'application/grpc-web+proto','x-grpc-web':'1','x-user-agent':'connect-es/2.1.1'}},body:fb.buffer}});return r.headers.get('grpc-status')||'0';}})()")
return s == '0'
async def _prepare_signup_page(page, *, redirect=True, timeout=30000):
if PAGE_BLOCK_STATIC_ASSETS:
await page.route("**/*", _route_static_asset_filter)
url = f'{SITE_URL}/sign-up?redirect=grok-com' if redirect else f'{SITE_URL}/sign-up'
await page.goto(url, timeout=timeout, wait_until=PAGE_GOTO_WAIT_UNTIL)
if PAGE_POST_WAIT_MS > 0:
await page.wait_for_timeout(PAGE_POST_WAIT_MS)
async def _route_static_asset_filter(route):
req = route.request
if (
req.resource_type in ("image", "font", "media", "stylesheet")
or "/_next/static/" in req.url
or "analytics" in req.url
):
await route.abort()
return
await route.continue_()
async def grpc_verify_code(page, email, code):
inner = pb_str(1, email) + pb_str(2, code)
frame = b'\x00' + struct.pack('>I', len(inner)) + inner
fb64 = base64.b64encode(frame).decode()
s = await page.evaluate(f"(async()=>{{var fb=Uint8Array.from(atob('{fb64}'),c=>c.charCodeAt(0));var r=await fetch('{SITE_URL}/auth_mgmt.AuthManagement/VerifyEmailValidationCode',{{method:'POST',headers:{{'content-type':'application/grpc-web+proto','x-grpc-web':'1','x-user-agent':'connect-es/2.1.1'}},body:fb.buffer}});return r.headers.get('grpc-status')||'0';}})()")
if REGISTRATION_DIAGNOSTICS and s != '0':
debug_log('[C] verify rejected')
return s == '0'
def auth_cookie_snapshot(cookies):
"""保留认证所需 Cookie 的原始作用域;不写入邮箱密码。"""
fields = ("name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite")
return [
{field: cookie[field] for field in fields if field in cookie}
for cookie in cookies
if cookie.get("name") in {"sso", "sso-rw"}
]
async def server_action_register(
page,
email,
password,
code,
turnstile_token,
*,
include_session=False,
):
payload = json.dumps([{
"emailValidationCode": code,
"createUserAndSessionRequest": {
"email": email,
"givenName": random.choice(["James","John","Robert","Michael","William","David","Richard","Joseph","Thomas","Charles"]),
"familyName": random.choice(["Smith","Johnson","Williams","Brown","Jones","Garcia","Miller","Davis","Rodriguez","Martinez"]),
"clearTextPassword": password, "tosAcceptedVersion": "$undefined"
},
"turnstileToken": turnstile_token, "promptOnDuplicateEmail": True
}])
pb64 = base64.b64encode(payload.encode()).decode()
if REGISTRATION_DIAGNOSTICS:
diagnostic_json = await page.evaluate(f"""(async()=>{{var r=await fetch('{SITE_URL}/sign-up',{{method:'POST',headers:{{'accept':'text/x-component','content-type':'text/plain;charset=UTF-8','next-router-state-tree':'{STATE_TREE}','next-action':'{ACTION_ID}'}},body:atob('{pb64}')}});return JSON.stringify({{status:r.status,retryAfter:r.headers.get('retry-after')||'',text:await r.text()}});}})()""")
diagnostic = json.loads(diagnostic_json)
result_text = diagnostic['text']
else:
diagnostic = None
result_text = await page.evaluate(f"""(async()=>{{var r=await fetch('{SITE_URL}/sign-up',{{method:'POST',headers:{{'accept':'text/x-component','content-type':'text/plain;charset=UTF-8','next-router-state-tree':'{STATE_TREE}','next-action':'{ACTION_ID}'}},body:atob('{pb64}')}});return await r.text();}})()""")
# 注册响应里带一个 set-cookie 重定向 URL,必须访问它,x.ai 才会下发真正的 sso cookie(152 字符 JWT)。
# 注意:直接解 q= 里的 JWT 取 config.token 是错的——那是 120 字符内部 blob,不是 sso 凭证。
text = result_text.replace('\\/', '/') # RSC 里 / 被转义成 \/
m = re.search(r'(https://[^" \s\\]+set-cookie\?q=[^:" \s\\]+)1:', text)
if not m:
m = re.search(r'(https://[^" \s\\]+set-cookie\?q=[A-Za-z0-9_.\-]+)', text)
if not m:
markers = _signup_response_markers(result_text)
if diagnostic is not None:
debug_log(
f"[C] signup no session http_status={diagnostic['status']} "
f"retry_after={diagnostic['retryAfter'] or '-'} response_bytes={len(result_text)} "
f"markers={markers}"
)
if "rate_limited" in markers:
raise RegistrationRateLimited("signup_rate_limited")
return None
url = m.group(1)
if C_SET_COOKIE_VIA_REQUEST:
try:
await page.context.request.get(url, timeout=15000)
cookies = await page.context.cookies()
sso = next((c['value'] for c in cookies if c['name'] == 'sso'), None)
if sso:
if include_session:
return sso, auth_cookie_snapshot(cookies)
return sso
except asyncio.CancelledError:
raise
except Exception:
pass
# 首方导航访问该 URL,浏览器正常落 sso cookie(跨域 fetch 会被 CORS/三方cookie 拦)
try:
await page.goto(url, timeout=15000, wait_until='domcontentloaded')
except asyncio.CancelledError:
raise
except Exception:
pass
cookies = await page.context.cookies()
sso = next((c['value'] for c in cookies if c['name'] == 'sso'), None)
if REGISTRATION_DIAGNOSTICS and not sso:
debug_log('[C] signup set-cookie completed without sso cookie')
if not sso:
return None
if include_session:
return sso, auth_cookie_snapshot(cookies)
return sso
# solver 预热页面池:复用已停在 sign-up 的页面,省掉每次 page.goto 的重型 SPA 加载
# SOLVER_REUSE=0 可关闭(用于 A/B 对比 goto 优化的增益)
SOLVER_REUSE = (os.environ.get("SOLVER_REUSE", "1").strip().lower() not in ("0", "false", "no"))
_solver_pool = []
_solver_lock = asyncio.Lock()
MAX_SOLVER_REUSE = _env_int("MAX_SOLVER_REUSE", 25)
SOLVER_INITIAL_WAIT_MS = _env_int("SOLVER_INITIAL_WAIT_MS", 500)
SOLVER_POLL_INTERVAL_MS = _env_int("SOLVER_POLL_INTERVAL_MS", 500)
SOLVER_POLL_ATTEMPTS = _env_int("SOLVER_POLL_ATTEMPTS", 100)
SOLVER_HARD_TIMEOUT = max(10, _env_int("SOLVER_HARD_TIMEOUT", 90))
SOLVER_CLEANUP_TIMEOUT = max(1, _env_int("SOLVER_CLEANUP_TIMEOUT", 5))
SOLVER_FAST_CLICK = (os.environ.get("SOLVER_FAST_CLICK", "1").strip().lower() not in ("0", "false", "no"))
SOLVER_MOUSE_CLICK_RETRIES = _env_int("SOLVER_MOUSE_CLICK_RETRIES", 3)
SOLVER_MOUSE_CLICK_INTERVAL_MS = _env_int("SOLVER_MOUSE_CLICK_INTERVAL_MS", 600)
SOLVER_TIMELINE_TRACE = (os.environ.get("SOLVER_TIMELINE_TRACE", "0").strip().lower() in ("1", "true", "yes"))
SOLVER_TIMELINE_SAMPLE = _env_int("SOLVER_TIMELINE_SAMPLE", 8)
_solver_timeline_emitted = 0
_solver_timeline_next_id = 0
def _new_solver_timeline(*, enabled=None):
global _solver_timeline_next_id
enabled = SOLVER_TIMELINE_TRACE if enabled is None else enabled
if not enabled:
return None
_solver_timeline_next_id += 1
return {"start": time.time(), "solve_id": _solver_timeline_next_id, "events": []}
def _trace_solver_event(timeline, event, **fields):
if timeline is None:
return
item = {"t": round(time.time() - timeline["start"], 4), "event": event}
if "solve_id" in timeline:
item["solve_id"] = timeline["solve_id"]
item.update(fields)
timeline["events"].append(item)
def _new_solver_poll_stats():
return {
"poll_attempts": 0,
"first_token_attempt": None,
"poll_read_ms_total": 0.0,
"poll_read_ms_max": 0.0,
"poll_read_count": 0,
"poll_retry_click_count": 0,
}
def _finish_solver_poll_stats(stats):
if not stats:
return {}
read_count = stats.pop("poll_read_count", 0)
total = stats.pop("poll_read_ms_total", 0.0)
stats["poll_read_ms_avg"] = round(total / read_count, 1) if read_count else 0.0
stats["poll_read_ms_max"] = round(stats.get("poll_read_ms_max", 0.0), 1)
return stats
async def _turnstile_frame_count(p):
try:
return await p.evaluate(
"() => document.querySelectorAll('iframe[src*=turnstile], iframe[src*=challenges.cloudflare.com]').length"
)
except asyncio.CancelledError:
raise
except Exception:
return 0
async def _turnstile_dom_snapshot(p):
try:
return await p.evaluate(
"""() => {
const clip = (value, n = 96) => String(value || "").slice(0, n);
const rectInfo = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
x: Math.round(r.left),
y: Math.round(r.top),
w: Math.round(r.width),
h: Math.round(r.height),
visible: r.width >= 1 && r.height >= 1
};
};
const elemInfo = (el) => {
if (!el) return null;
return {
tag: clip(el.tagName),
id: clip(el.id, 64),
class: clip(el.className, 96),
is_iframe: el.tagName === "IFRAME"
};
};
const urlInfo = (src) => {
try {
const u = new URL(src || "", location.href);
return {host: clip(u.host, 96), path: clip(u.pathname, 96)};
} catch (_) {
return {host: "", path: ""};
}
};
const widget = document.querySelector(".cf-turnstile");
const wr = rectInfo(widget);
const center = wr ? {
x: Math.round(wr.x + wr.w / 2),
y: Math.round(wr.y + wr.h / 2)
} : null;
const centerEl = center ? document.elementFromPoint(center.x, center.y) : null;
const iframes = Array.from(document.querySelectorAll("iframe"));
const iframeSummaries = iframes.slice(0, 8).map((f) => {
const info = Object.assign(urlInfo(f.getAttribute("src") || ""), rectInfo(f) || {});
info.in_widget = widget ? widget.contains(f) : false;
return info;
});
const isTurnstileFrame = (f) => {
const src = f.getAttribute("src") || "";
return src.includes("turnstile") || src.includes("challenges.cloudflare.com");
};
const response = document.querySelector('input[name="cf-turnstile-response"]');
return {
__csp_solver_snapshot: true,
ready_state: document.readyState,
viewport: {w: window.innerWidth, h: window.innerHeight},
widget: Object.assign({present: Boolean(widget)}, wr || {}),
click_center: center,
element_at_center: elemInfo(centerEl),
all_iframe_count: iframes.length,
turnstile_iframe_count: iframes.filter(isTurnstileFrame).length,
iframe_summaries: iframeSummaries,
turnstile_loaded: Boolean(window.turnstile),
response_input: {
present: Boolean(response),
token_len: response && response.value ? response.value.length : 0
}
};
}"""
)
except asyncio.CancelledError:
raise
except Exception:
return {}
async def _turnstile_page_trace(p):
try:
return await p.evaluate(
"() => window.__cspTurnstileTrace ? Object.assign({}, window.__cspTurnstileTrace) : null"
)
except asyncio.CancelledError:
raise
except Exception:
return None
async def _get_solver_page(browser):
if SOLVER_REUSE:
async with _solver_lock:
if _solver_pool:
item = _solver_pool.pop()
item["reused"] = True
item["goto_s"] = 0.0
return item
p = await _prepare_browser_page(browser)
await p.set_viewport_size({"width": 800, "height": 600})
goto_started = time.time()
await p.goto(f'{SITE_URL}/sign-up', timeout=20000)
await p.wait_for_timeout(1000)
return {"page": p, "n": 0, "reused": False, "goto_s": time.time() - goto_started}
async def _put_solver_page(item, ok):
p = item["page"]
item["n"] += 1
if SOLVER_REUSE and ok and item["n"] < MAX_SOLVER_REUSE:
try: # 清理本次注入痕迹,留待复用
await asyncio.wait_for(
p.evaluate("document.querySelectorAll('.cf-turnstile').forEach(e=>e.remove());var i=document.querySelector('input[name=\"cf-turnstile-response\"]');if(i)i.remove();"),
timeout=SOLVER_CLEANUP_TIMEOUT,
)
async with _solver_lock:
_solver_pool.append(item)
return
except asyncio.CancelledError:
await _close_solver_page(item)
raise
except Exception:
pass
await _close_solver_page(item)
async def _close_solver_page(item):
"""Bound cleanup so a wedged renderer cannot trap the worker in finally."""
page = item["page"]
try:
await asyncio.wait_for(page.close(), timeout=SOLVER_CLEANUP_TIMEOUT)
except asyncio.CancelledError:
# Solver cancellation is expected at the hard deadline. Give cleanup
# its own bounded task so the page is not returned to the reuse pool.
cleanup = asyncio.create_task(page.close())
try:
await asyncio.wait_for(cleanup, timeout=SOLVER_CLEANUP_TIMEOUT)
except BaseException:
cleanup.cancel()
raise
except Exception:
pass
async def _inject_turnstile_widget(p, *, timeline=False):
if not timeline:
await p.evaluate(f"""var d=document.createElement('div');d.className='cf-turnstile';d.setAttribute('data-sitekey','{SITE_KEY}');d.style.cssText='position:fixed;top:10px;left:10px;z-index:99999;background:white;padding:12px;border:2px solid red;border-radius:6px;width:300px;height:70px';document.body.appendChild(d);function __r(){{window.turnstile&&window.turnstile.render(d,{{sitekey:'{SITE_KEY}',callback:function(t){{var i=document.querySelector('input[name="cf-turnstile-response"]');if(!i){{i=document.createElement('input');i.type='hidden';i.name='cf-turnstile-response';document.body.appendChild(i);}}i.value=t;}}}})}}if(window.turnstile){{__r()}}else{{var s=document.createElement('script');s.src='https://challenges.cloudflare.com/turnstile/v0/api.js';s.onload=function(){{setTimeout(__r,1000)}};document.head.appendChild(s);}}""")
return
await p.evaluate(f"""var __trace=window.__cspTurnstileTrace={{created_at:performance.now(),script_inserted_at:null,script_loaded_at:null,render_called_at:null,render_returned_at:null,token_written_at:null,token_len:0,error:null}};var d=document.createElement('div');d.className='cf-turnstile';d.setAttribute('data-sitekey','{SITE_KEY}');d.style.cssText='position:fixed;top:10px;left:10px;z-index:99999;background:white;padding:12px;border:2px solid red;border-radius:6px;width:300px;height:70px';document.body.appendChild(d);function __r(){{try{{if(!window.turnstile)return;__trace.render_called_at=performance.now();var __ret=window.turnstile.render(d,{{sitekey:'{SITE_KEY}',callback:function(t){{var i=document.querySelector('input[name="cf-turnstile-response"]');if(!i){{i=document.createElement('input');i.type='hidden';i.name='cf-turnstile-response';document.body.appendChild(i);}}i.value=t;__trace.token_written_at=performance.now();__trace.token_len=t?t.length:0;}}}});__trace.render_returned_at=performance.now();__trace.render_return_type=typeof __ret;}}catch(e){{__trace.error=e&&e.name?e.name:String(e);}}}}if(window.turnstile){{__r()}}else{{var s=document.createElement('script');s.src='https://challenges.cloudflare.com/turnstile/v0/api.js';s.onload=function(){{__trace.script_loaded_at=performance.now();setTimeout(__r,1000)}};__trace.script_inserted_at=performance.now();document.head.appendChild(s);}}""")
async def _has_visible_turnstile_frame(p):
try:
return await p.evaluate(
"""() => Array.from(document.querySelectorAll('iframe')).some((f) => {
const r = f.getBoundingClientRect();
return r.width >= 20 && r.height >= 20;
})"""
)
except Exception:
return False
async def _read_turnstile_token(p):
try:
return await p.evaluate('document.querySelector("input[name=\\"cf-turnstile-response\\"]")?.value||""')
except asyncio.CancelledError:
raise
except Exception:
return ""
async def _mouse_click_turnstile_center(p):
clicked, _trace = await _mouse_click_turnstile_center_trace(p)
return clicked
async def _mouse_click_turnstile_center_trace(p):
trace = {}
started = time.time()
box = await p.evaluate(
"""() => {
const e = document.querySelector('.cf-turnstile');
if (!e) return null;
const r = e.getBoundingClientRect();
return {x: r.left + r.width / 2, y: r.top + r.height / 2};
}"""
)
trace["box_eval_ms"] = round((time.time() - started) * 1000, 1)
if not box:
return False, trace
x = float(box["x"])
y = float(box["y"])
trace["click_x"] = round(x, 1)
trace["click_y"] = round(y, 1)
started = time.time()
await p.mouse.move(max(0, x - 25), max(0, y - 8))
trace["mouse_move1_ms"] = round((time.time() - started) * 1000, 1)
started = time.time()
await p.mouse.move(x, y, steps=8)
trace["mouse_move2_ms"] = round((time.time() - started) * 1000, 1)
started = time.time()
await p.mouse.down()
trace["mouse_down_ms"] = round((time.time() - started) * 1000, 1)
await asyncio.sleep(0.05)
started = time.time()
await p.mouse.up()
trace["mouse_up_ms"] = round((time.time() - started) * 1000, 1)
return True, trace
async def _repeat_mouse_click_turnstile(p, *, timeline=None):
retries = max(0, SOLVER_MOUSE_CLICK_RETRIES)
if retries <= 0:
return False
clicked = False
interval = max(50, SOLVER_MOUSE_CLICK_INTERVAL_MS) / 1000.0
for i in range(retries):
token = await _read_turnstile_token(p)
dom = await _turnstile_dom_snapshot(p) if timeline is not None else None
iframe_count = dom.get("turnstile_iframe_count", 0) if dom else 0
_trace_solver_event(
timeline, "click_before", attempt=i + 1,
token_len=len(token or ""), iframe_count=iframe_count, dom=dom
)
if token and len(token) > 10:
return clicked
click_started = time.time()
click_error = None
try:
if timeline is not None:
attempt_clicked, click_trace = await _mouse_click_turnstile_center_trace(p)
else:
attempt_clicked = await _mouse_click_turnstile_center(p)
click_trace = {}
clicked = attempt_clicked or clicked
except asyncio.CancelledError:
raise
except Exception as exc:
click_error = type(exc).__name__
attempt_clicked = False
click_trace = {}
click_call_ms = round((time.time() - click_started) * 1000, 1)
token = await _read_turnstile_token(p) if timeline is not None else ""
dom = await _turnstile_dom_snapshot(p) if timeline is not None else None
iframe_count = dom.get("turnstile_iframe_count", 0) if dom else 0
_trace_solver_event(
timeline, "click_after", attempt=i + 1, clicked=clicked,
attempt_clicked=attempt_clicked,
token_len=len(token or ""), iframe_count=iframe_count,
click_call_ms=click_call_ms, click_error=click_error,
click_trace=click_trace, dom=dom
)
if i != retries - 1:
await asyncio.sleep(interval)
return clicked
async def _click_turnstile_if_possible(p, *, fast=False, timeline=None):
if SOLVER_MOUSE_CLICK_RETRIES > 0:
return await _repeat_mouse_click_turnstile(p, timeline=timeline)
visible = await _has_visible_turnstile_frame(p)
_trace_solver_event(timeline, "visible_check", visible=visible)
if fast and not visible:
return visible
click_timeout = 500 if fast else 3000
for sel in ["iframe[src*='challenges.cloudflare.com']","iframe[src*='turnstile']",".cf-turnstile iframe"]:
try:
fr = p.frame_locator(sel).first
await fr.locator("#checkbox, .checkbox, input[type=checkbox], body").first.click(timeout=click_timeout)
break
except asyncio.CancelledError:
raise
except Exception:
continue
return visible
async def _poll_turnstile_token(p, *, stats=None):
for i in range(SOLVER_POLL_ATTEMPTS):
await asyncio.sleep(max(50, SOLVER_POLL_INTERVAL_MS) / 1000)
if stats is not None:
stats["poll_attempts"] = i + 1
read_started = time.time()
try:
t = await _read_turnstile_token(p)
if stats is not None:
read_ms = (time.time() - read_started) * 1000
stats["poll_read_count"] += 1
stats["poll_read_ms_total"] += read_ms
stats["poll_read_ms_max"] = max(stats["poll_read_ms_max"], read_ms)
if t and len(t) > 10:
if stats is not None and stats["first_token_attempt"] is None:
stats["first_token_attempt"] = i + 1
return t
except asyncio.CancelledError:
raise
except Exception:
pass
retry_every = max(1, int(10000 / max(50, SOLVER_POLL_INTERVAL_MS)))
if i > 0 and i % retry_every == 0:
try:
await p.locator(".cf-turnstile").first.click(timeout=1000)
if stats is not None:
stats["poll_retry_click_count"] += 1
except asyncio.CancelledError:
raise
except Exception:
pass
return None
async def _start_turnstile_challenge(browser, *, fast_click=False):
global _solver_timeline_emitted
item = await _get_solver_page(browser)
p = item["page"]
trace_timeline = SOLVER_TIMELINE_TRACE and _solver_timeline_emitted < SOLVER_TIMELINE_SAMPLE
if trace_timeline:
_solver_timeline_emitted += 1
timeline = _new_solver_timeline(enabled=trace_timeline)
trace = {
"goto_s": item.get("goto_s", 0.0),
"reused": bool(item.get("reused", False)),
"reuse_count": item.get("n", 0),
"inject_s": 0.0,
"initial_s": 0.0,
"click_s": 0.0,
"wait_s": 0.0,
"visible_frame": False,
}
item["trace"] = trace
item["timeline"] = timeline
try:
stage_started = time.time()
_trace_solver_event(timeline, "inject_start")
await _inject_turnstile_widget(p, timeline=timeline is not None)
trace["inject_s"] = time.time() - stage_started
_trace_solver_event(timeline, "inject_done")
if timeline is not None:
_trace_solver_event(
timeline, "page_trace_after_inject",
page_trace=await _turnstile_page_trace(p)
)
stage_started = time.time()
await p.wait_for_timeout(SOLVER_INITIAL_WAIT_MS)
trace["initial_s"] = time.time() - stage_started
_trace_solver_event(timeline, "initial_done")
if timeline is not None:
_trace_solver_event(
timeline, "page_trace_after_initial",
page_trace=await _turnstile_page_trace(p)
)
stage_started = time.time()
trace["visible_frame"] = await _click_turnstile_if_possible(
p, fast=fast_click, timeline=timeline
)
trace["click_s"] = time.time() - stage_started
_trace_solver_event(timeline, "click_stage_done", clicked=bool(trace["visible_frame"]))
if timeline is not None:
_trace_solver_event(
timeline, "page_trace_after_click",
page_trace=await _turnstile_page_trace(p)
)
return item
except BaseException:
await _put_solver_page(item, False)
raise
async def _wait_turnstile_challenge(item):
ok = False
try:
wait_started = time.time()
timeline = item.get("timeline")
poll_stats = _new_solver_poll_stats() if timeline is not None else None
_trace_solver_event(timeline, "poll_start")
token = await _poll_turnstile_token(item["page"], stats=poll_stats)
item.get("trace", {})["wait_s"] = time.time() - wait_started
ok = token is not None
page_trace = await _turnstile_page_trace(item["page"]) if timeline is not None else None
_trace_solver_event(
timeline, "poll_done", ok=ok, token_len=len(token or ""),
page_trace=page_trace, **_finish_solver_poll_stats(poll_stats)
)
return token
except asyncio.CancelledError:
raise
except Exception:
return None
finally:
timeline = item.get("timeline")
if timeline is not None:
debug_log("[solver_timeline] " + json.dumps(timeline["events"], separators=(",", ":")))
await _put_solver_page(item, ok)
async def solve_one_turnstile(browser):
token, _trace = await solve_one_turnstile_with_trace(browser)
return token
async def solve_one_turnstile_with_trace(browser):
item = await _start_turnstile_challenge(browser, fast_click=SOLVER_FAST_CLICK)
token = await _wait_turnstile_challenge(item)
return token, item.get("trace", {})
def _record_solver_trace(metrics, trace, total_seconds, token):
metrics.t_solve_count += 1
metrics.t_solve_seconds += total_seconds
if token is None:
metrics.t_solve_failed += 1
if not trace:
return
metrics.solver_goto_seconds += trace.get("goto_s", 0.0)
metrics.solver_inject_seconds += trace.get("inject_s", 0.0)
metrics.solver_initial_seconds += trace.get("initial_s", 0.0)
metrics.solver_click_seconds += trace.get("click_s", 0.0)
metrics.solver_wait_seconds += trace.get("wait_s", 0.0)
if trace.get("reused"):
metrics.solver_reused_count += 1
if trace.get("visible_frame"):
metrics.solver_visible_frame_count += 1
# ──────────────────────────────────────────────
# 邮箱服务:custom(自建 webhook) / tempmail(免费临时邮箱,多 provider fallback)
# ──────────────────────────────────────────────
# 免 key 的公共临时邮箱 provider(实测可用,互为 fallback 消灭单点):
# - mail.tm 同协议:mail.tm / mail.gw / duckmail.sbs
# - 独立 API:tempmail.lol
# handle 编码 provider,供 poll_code 分派;新增 provider 只要在这两处加一段即可。
TEMPMAIL_BASES = ["https://api.mail.tm", "https://api.mail.gw", "https://api.duckmail.sbs"]
def _extract_code(text):
"""多层兜底提取验证码,抗邮件模板变化。"""
for pat in (r'>([A-Z0-9]{3}-[A-Z0-9]{3})<', r'>([A-Z0-9]{6})<', r'\b([A-Z0-9]{3}-?[A-Z0-9]{3})\b'):
m = re.search(pat, text)
if m:
return m.group(1).replace('-', '')
return None
def _mailtm_create(base, password):
"""mail.tm 同协议建箱;返回 (handle, email)。"""
d = req.get(f'{base}/domains', timeout=12).json()
d = d.get('hydra:member', d) if isinstance(d, dict) else d
doms = [x['domain'] for x in d if x.get('isActive', True) and not x.get('isPrivate', False)]
if not doms:
raise RuntimeError('no domain')
email = f'oc{secrets.token_hex(5)}@{doms[0]}'
req.post(f'{base}/accounts', json={'address': email, 'password': password}, timeout=12)
tok = req.post(f'{base}/token', json={'address': email, 'password': password}, timeout=12).json().get('token', '')
if not tok:
raise RuntimeError('no token')
return f'mt|{base}|{tok}', email
def _lol_create():
"""tempmail.lol 建箱;返回 (handle, email)。"""
r = req.post('https://api.tempmail.lol/v2/inbox/create', timeout=12).json()
addr, tok = r.get('address', ''), r.get('token', '')
if not addr or not tok:
raise RuntimeError('lol create failed')
return f'lol|{tok}', addr
def create_email():
"""custom 用自建域名(本地 webhook);tempmail 随机打散多个 provider,逐个 fallback。"""
if EMAIL_MODE == 'custom':
email = f'oc{secrets.token_hex(5)}@{EMAIL_DOMAIN}'
password = rand_str()
return email, email, password # 地址即用,验证码经 CF Worker POST 到本地 webhook
password = rand_str()
# 优先 TempMail.lol(mail.tm 的 /token POST 在本机 fake-IP 隧道下 SSL 握手僵死、
# req.post 的 timeout 不覆盖 _create 阶段 → 卡住永不 raise → try/except fallback
# 够不着「卡死」状态 → 第一 maker 把整条 P 焊死。故 lol 先跑,mail.tm 后置兜底)。
makers = [_lol_create] + [(lambda b=b: _mailtm_create(b, password)) for b in TEMPMAIL_BASES]
for make in makers:
try:
handle, email = make()
return handle, email, password
except Exception:
continue
raise RuntimeError('所有临时邮箱 provider 均不可用')
def _tempmail_fetch(handle):
"""按 handle 前缀分派,取该邮箱当前邮件全文(subject+text+html);无则 None。"""
kind = handle.split('|', 1)[0]
if kind == 'lol':
tok = handle.split('|', 1)[1]
data = req.get(f'https://api.tempmail.lol/v2/inbox?token={tok}', timeout=10).json()
items = data.get('emails') or data.get('messages') or []
if not items:
return None
return '\n'.join(f"{i.get('subject','')}\n{i.get('body','')}\n{i.get('html','')}"
for i in items if isinstance(i, dict))
# mail.tm 同协议:handle = "mt|base|token"
_, base, tok = handle.split('|', 2)
hdr = {'Accept': 'application/json', 'Authorization': f'Bearer {tok}'}
data = req.get(f'{base}/messages', headers=hdr, timeout=10).json()
msgs = data if isinstance(data, list) else data.get('hydra:member', [])
if not msgs:
return None
mid = str(msgs[0].get('id') or '')
detail = req.get(f'{base}/messages/{mid}', headers=hdr, timeout=10).json()
parts = [str(detail.get(k, '')) for k in ['subject', 'intro', 'text', 'html']]
if isinstance(detail.get('html'), list):
parts.append('\n'.join(str(x) for x in detail['html']))
return '\n'.join(parts)
def poll_code(handle, max_wait=90):
"""轮询验证码:custom 查本地 webhook /check;tempmail 按 provider 取信。"""
if EMAIL_MODE == 'custom':
for _ in range(max_wait):
time.sleep(1)
try:
resp = req.get(f'{LOCAL_EMAIL_API}/check/{handle}', timeout=5)
if resp.status_code == 200 and resp.json().get('code'):
return resp.json()['code']
except Exception:
pass
return None
for _ in range(max_wait):
time.sleep(1)
try:
text = _tempmail_fetch(handle)
if text:
code = _extract_code(text)
if code:
return code
except Exception:
pass
return None
async def _create_email_async(loop):
"""在线程池中创建邮箱,避免阻塞 asyncio 事件循环。"""
return await loop.run_in_executor(POLL_EXECUTOR, create_email)
async def _poll_code_async(loop, handle):
"""在线程池中轮询验证码。"""
return await loop.run_in_executor(POLL_EXECUTOR, poll_code, handle)
async def _acquire_many(sem, count):
"""一次预留多个许可;取消或异常时回滚已获取许可。"""
acquired = 0
try:
for _ in range(count):
await sem.acquire()
acquired += 1
return acquired
except BaseException:
for _ in range(acquired):
sem.release()
raise
class _NoopAsyncSemaphore:
async def acquire(self):
return True
def release(self):
return None
async def _send_q_request_batch(browser, physical_sem, p_send_sem, requests, metrics=None):
"""使用一个页面发送一批 Q 请求。
返回每个请求的 sent 状态。等待 Q 返回不在此函数内发生,因此这里释放
Physical_Sem 后不会占用本地重资源。
"""
p_send_acquired = False
physical_acquired = False
physical_wait_started = None
physical_hold_started = None
page = None
await p_send_sem.acquire()
p_send_acquired = True
try:
physical_wait_started = time.time()
await physical_sem.acquire()
physical_acquired = True
physical_hold_started = time.time()
if metrics is not None:
metrics.p_physical_count += 1
metrics.p_physical_wait_seconds += physical_hold_started - physical_wait_started
page = await _prepare_browser_page(browser)
await page.set_viewport_size({"width": 800, "height": 600})
try:
stage_started = time.time()
await _prepare_signup_page(page, redirect=True, timeout=30000)
if metrics is not None:
metrics.p_page_prepare_count += 1
metrics.p_page_prepare_seconds += time.time() - stage_started
except asyncio.CancelledError:
raise
except Exception:
return [{**item, "sent": False} for item in requests]
results = []
send_started = time.time()
for item in requests:
sent = False
try:
sent = await grpc_create_code(page, item["email"])
except asyncio.CancelledError:
raise
except Exception:
sent = False
results.append({**item, "sent": sent})
if metrics is not None:
metrics.p_send_count += 1
metrics.p_send_seconds += time.time() - send_started
return results
finally:
if page is not None:
try:
await page.close()
except Exception:
pass
if physical_acquired:
if metrics is not None and physical_hold_started is not None:
metrics.p_physical_hold_seconds += time.time() - physical_hold_started
physical_sem.release()
if p_send_acquired:
p_send_sem.release()
async def _poll_and_admit_q(
request,
inventory,
q_pending_sem,
q_slot_sem,
metrics,
*,
q_batch_lease=None,
admission_gate=None,
):
"""等待单个 Q 返回并入库;每个请求独立释放 pending/inflight。"""
loop = asyncio.get_event_loop()
release_reservation = True
try:
try:
code = await asyncio.wait_for(
_poll_code_async(loop, request["handle"]),
timeout=P_REQUEST_TIMEOUT,
)
except asyncio.CancelledError:
# poll_code 仍在运行时取消,底层轮询可能继续持有该请求;此处
# 不能提前归还 pending/inflight否则新请求会超量进入。
release_reservation = False
raise
except asyncio.TimeoutError:
code = None
if code is None:
metrics.q_discarded += 1
return False
metrics.q_returned += 1
returned_at = time.time()
q_env = None
try:
q_env = await ResourceEnvelope.create_with_slot(
'Q',
{
'email': request["email"],
'password': request["password"],
'code': code,
},
q_slot_sem,
expires_at=returned_at + Q_MAX_AGE,
)
await inventory.put_q(q_env)
debug_log('[P] verification code admitted')
return True
except asyncio.CancelledError:
if q_env is not None and not q_env.released:
q_env.discard()
raise
except Exception:
if q_env is not None and not q_env.released:
q_env.discard()
metrics.q_discarded += 1
return False
finally:
if release_reservation:
# poll 已终止(含超时/网络/解析异常),或 Q 已返回后任务在
# 入库阶段取消:请求所有权已经回到本协程,必须归还许可。
q_pending_sem.release()
if q_batch_lease is not None:
await q_batch_lease.release_one()
if admission_gate is not None:
await admission_gate.notify_changed()
def _observe_background_task(task):
"""Consume detached task failures so background settlement is not silent."""
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as e:
debug_log(f'[P] background settle err: {sanitize_terminal_error(e)}')
# ──────────────────────────────────────────────
# CSP Worker
# ──────────────────────────────────────────────
class _CHotPageLease:
def __init__(self, browser, metrics=None):
self.browser = browser
self.metrics = metrics
self.context = None
self.page = None
async def __aenter__(self):
started = time.time()
try:
self.context, self.page = await _acquire_c_page(self.browser, self.metrics)
return self.page
finally:
if self.metrics is not None:
self.metrics.c_page_acquire_count += 1
self.metrics.c_page_acquire_seconds += time.time() - started
async def __aexit__(self, exc_type, exc, tb):
await _release_c_page(self.context, self.page, healthy=exc_type is None)
self.context = None
self.page = None
return False
_c_hot_page_pool = []
_c_hot_page_lock = asyncio.Lock()
_c_hot_page_pool_size = derive_c_hot_page_pool_size(PHYSICAL_CAP or 1, C_WORKERS or 1)
async def _new_c_hot_page(browser):
context = await _prepare_browser_context(browser)
page = await context.new_page()
await page.set_viewport_size({"width": 800, "height": 600})
await _prepare_signup_page(page, redirect=True, timeout=30000)
return context, page
async def _acquire_c_page(browser, metrics=None):
if C_HOT_PAGE_POOL:
async with _c_hot_page_lock:
if _c_hot_page_pool:
if metrics is not None:
metrics.c_hot_page_hits += 1
return _c_hot_page_pool.pop()
if metrics is not None:
metrics.c_hot_page_misses += 1
return await _new_c_hot_page(browser)
page = await _prepare_browser_page(browser)
await page.set_viewport_size({"width": 800, "height": 600})
await _prepare_signup_page(page, redirect=True, timeout=30000)
return None, page
async def _release_c_page(context, page, *, healthy):
if not C_HOT_PAGE_POOL or context is None:
try:
await page.close()
except Exception:
pass
return
if healthy:
try:
if "/sign-up" not in (getattr(page, "url", "") or ""):
healthy = False
else:
await context.clear_cookies()
await page.evaluate(
"() => { try { localStorage.clear(); sessionStorage.clear(); } catch (e) {} }"
)
async with _c_hot_page_lock:
if len(_c_hot_page_pool) < _c_hot_page_pool_size:
_c_hot_page_pool.append((context, page))
return
except asyncio.CancelledError:
try:
await context.close()
except Exception:
pass
raise
except Exception:
pass
try:
await context.close()
except Exception:
try:
await page.close()
except Exception:
pass
def _c_page_lease(browser, metrics=None):
return _CHotPageLease(browser, metrics)
async def _close_c_hot_page_pool():
async with _c_hot_page_lock:
items = list(_c_hot_page_pool)
_c_hot_page_pool.clear()
for context, page in items:
try:
await context.close()
except Exception:
try:
await page.close()
except Exception:
pass
async def s_worker(wid, browser, inventory, physical_sem, t_slot_sem, metrics, admission_gate=None):
"""S_Worker: 生成 T 并入库。"""
while not STOP.is_set():
t_lease = None
try:
if admission_gate is not None:
t_lease = await admission_gate.acquire_t_production()
physical_wait_started = time.time()
await physical_sem.acquire()
physical_hold_started = time.time()
metrics.s_physical_count += 1
metrics.s_physical_wait_seconds += physical_hold_started - physical_wait_started
token = None
trace = {}
solve_started = time.time()
try:
try:
token, trace = await asyncio.wait_for(
solve_one_turnstile_with_trace(browser),
timeout=SOLVER_HARD_TIMEOUT,
)
except asyncio.TimeoutError:
debug_log(f'[S] {wid} solver timeout after {SOLVER_HARD_TIMEOUT}s')
except asyncio.CancelledError:
raise
except Exception as exc:
debug_log(f'[S] {wid} solver error: {sanitize_terminal_error(exc)}')
finally:
solve_elapsed = time.time() - solve_started
_record_solver_trace(metrics, trace, solve_elapsed, token)
metrics.s_physical_hold_seconds += time.time() - physical_hold_started
physical_sem.release()
if token is None:
metrics.t_discarded += 1
if t_lease is not None:
await t_lease.release()
t_lease = None
await asyncio.sleep(0.5)
continue
metrics.t_produced += 1
now = time.time()
t_env = None
try:
t_env = await ResourceEnvelope.create_with_slot(
'T', token, t_slot_sem, expires_at=now + T_MAX_AGE
)
await inventory.put_t(t_env)
if admission_gate is not None:
await admission_gate.notify_changed()
except asyncio.CancelledError:
if t_env is not None and not t_env.released:
t_env.discard()
metrics.t_discarded += 1
raise
except Exception:
if t_env is not None and not t_env.released:
t_env.discard()
metrics.t_discarded += 1
except asyncio.CancelledError:
raise
except Exception as exc:
# A single browser/page failure must not terminate the permanent
# producer. The next iteration acquires a fresh solver page.
metrics.t_discarded += 1
debug_log(f'[S] {wid} worker error: {sanitize_terminal_error(exc)}')
finally:
if t_lease is not None:
await t_lease.release()
await asyncio.sleep(0.2)
async def p_worker(
wid,
browser,
inventory,
physical_sem,
q_pending_sem,
q_slot_sem,
metrics,
admission_gate=None,
p_send_sem=None,
max_batch=1,
):
"""P_Worker: 创建邮箱 + 发码 + 轮询 + 入库。"""
loop = asyncio.get_event_loop()
p_send_sem = p_send_sem or _NoopAsyncSemaphore()
while not STOP.is_set():
q_lease = None
pending_owned = 0
settle_tasks = []
try:
if admission_gate is not None:
q_lease = await admission_gate.acquire_q_batch(max_batch=max_batch)
batch_count = q_lease.count
else:
batch_count = 1
pending_owned = await _acquire_many(q_pending_sem, batch_count)
requests = []
for _ in range(batch_count):
email_started = time.time()
try:
handle, email, password = await _create_email_async(loop)
metrics.p_email_create_count += 1
metrics.p_email_create_seconds += time.time() - email_started
requests.append({"handle": handle, "email": email, "password": password})
except asyncio.CancelledError:
raise
except Exception as e:
metrics.p_email_create_count += 1
metrics.p_email_create_seconds += time.time() - email_started
debug_log(f'[P] {wid} create email err: {sanitize_terminal_error(e)}')
metrics.q_discarded += 1
q_pending_sem.release()
pending_owned -= 1
if q_lease is not None:
await q_lease.release_one()
if not requests:
continue
results = await _send_q_request_batch(
browser, physical_sem, p_send_sem, requests, metrics
)
metrics.q_send_batches += 1
metrics.q_send_batch_items += len(results)
for item in results:
if not item["sent"]:
metrics.q_discarded += 1
q_pending_sem.release()
pending_owned -= 1
if q_lease is not None:
await q_lease.release_one()
continue
metrics.q_sent += 1
pending_owned -= 1
task = asyncio.create_task(
_poll_and_admit_q(
item,
inventory,
q_pending_sem,
q_slot_sem,
metrics,
q_batch_lease=q_lease,
admission_gate=admission_gate,
)
)
task.add_done_callback(_observe_background_task)
settle_tasks.append(task)
if settle_tasks:
await asyncio.gather(*(asyncio.shield(task) for task in settle_tasks))
except asyncio.CancelledError:
for _ in range(pending_owned):
q_pending_sem.release()
if q_lease is not None:
await q_lease.release_one()
raise
except Exception as e:
debug_log(f'[P] {wid} err: {sanitize_terminal_error(e)}')
metrics.q_discarded += 1
for _ in range(pending_owned):
q_pending_sem.release()
if q_lease is not None:
await q_lease.release_one()
await asyncio.sleep(0.2)
def _pair_is_expired(pair, now=None):
"""检查已 claim 的 T/Q 是否在等待期间失效。"""
now = time.time() if now is None else now
return any(
bool(check(now))
for envelope in (pair.t, pair.q)
if (check := getattr(envelope, "is_expired", None)) is not None
)
def _append_registration_line(path, line, mode=None, *, durable=False):
with open(path, "a") as stream:
stream.write(line)
if durable:
stream.flush()
os.fsync(stream.fileno())
if mode is not None:
os.chmod(path, mode)
def _persist_registration(email, password, sso, session_cookies):
if session_cookies:
document = json.dumps(
{"email": email, "cookies": session_cookies},
separators=(",", ":"),
)
_append_registration_line(
"keys/auth-sessions.jsonl",
document + "\n",
mode=0o600,
durable=True,
)
_append_registration_line("keys/accounts.txt", f"{email}:{password}:{sso}\n")
_append_registration_line("keys/grok.txt", sso + "\n")
async def _consume_pair(
browser,
physical_sem,
pair,
metrics,
task_id=None,
recovery_probe=None,
):
"""执行一次 C 消费。返回 True 表示业务成功,False 表示消费失败。"""
global success_count
email = pair.q.value['email']
password = pair.q.value['password']
code = pair.q.value['code']
token = pair.t.value
# c_worker 在单次消费超时开始前完成冷却等待。保留这里的默认路径,
# 供直接调用者使用,同时避免把 60 秒冷却计入 60 秒消费超时。
if recovery_probe is None:
recovery_probe = await REGISTRATION_RATE_LIMIT_CIRCUIT.wait()
# 直接调用路径也可能在 circuit.wait() 中跨过资源有效期。尚未开始
# 注册时只让出探针,不把本地过期误判成一次恢复探测失败。
if _pair_is_expired(pair):
if recovery_probe:
REGISTRATION_RATE_LIMIT_CIRCUIT.release_probe(recovery_probe)
return False
physical_acquired = False
physical_hold_started = None
recovery_completed = False
try:
physical_wait_started = time.time()
await physical_sem.acquire()
physical_acquired = True
physical_hold_started = time.time()
metrics.c_physical_count += 1
metrics.c_physical_wait_seconds += physical_hold_started - physical_wait_started
t0 = time.time()
try:
async with _c_page_lease(browser, metrics) as page:
sso = None
session_cookies = []
verify_started = time.time()
try:
verified = await grpc_verify_code(page, email, code)
finally:
metrics.c_verify_count += 1
metrics.c_verify_seconds += time.time() - verify_started
# 限流可能由另一项并发任务在本任务验证码校验期间触发。
# 非当前 probe 不得在 circuit 打开后开始新的注册提交;同时
# 避免使用校验过程中刚过期的 T/Q。
if (
verified
and not _pair_is_expired(pair)
and REGISTRATION_RATE_LIMIT_CIRCUIT.can_submit(recovery_probe)
):
register_started = time.time()
try:
registration = await server_action_register(
page,
email,
password,
code,
token,
include_session=True,
)
if registration:
sso, session_cookies = registration
finally:
metrics.c_register_count += 1
metrics.c_register_seconds += time.time() - register_started
except RegistrationRateLimited:
if REGISTRATION_RATE_LIMIT_CIRCUIT.trip():
log(
format_user_registration_event(
"rate_limited",
wait_seconds=REGISTRATION_RATE_LIMIT_CIRCUIT.remaining_seconds(),
)
)
return False
if sso:
elapsed = time.time() - t0
async with file_lock:
_persist_registration(email, password, sso, session_cookies)
metrics.record_success()
success_count = metrics.success_count
count = metrics.success_count
log(
format_user_registration_event(
"success",
task_id=task_id,
count=count,
rate_per_minute=metrics.runtime_average_success_rate(),
)
)
if recovery_probe:
recovered_after = REGISTRATION_RATE_LIMIT_CIRCUIT.consume_recovery_seconds(
recovery_probe
)
recovery_completed = True
if recovered_after is not None:
log(
format_user_registration_event(
"recovered", wait_seconds=round(recovered_after)
)
)
return True
log(format_user_registration_event("failed", task_id=task_id))
return False
finally:
if recovery_probe and not recovery_completed:
REGISTRATION_RATE_LIMIT_CIRCUIT.defer_probe(recovery_probe)
if physical_acquired:
metrics.c_physical_hold_seconds += time.time() - physical_hold_started
physical_sem.release()
async def c_worker(wid, browser, inventory, physical_sem, metrics, admission_gate=None):
"""C_Worker: claim pair 并执行注册。"""
while not STOP.is_set():
recovery_probe = False
task_id = None
try:
async with inventory.claim_pair() as pair:
if admission_gate is not None:
await admission_gate.notify_changed()
# 必须先 claim再通过冷却闸门。否则多个 worker 会在限流前
# 通过闸门,随后于冷却期陆续拿到 pair 并漏闸。等待发生在
# wait_for 之外,因此不计入单次 C 消费超时。
recovery_probe = await REGISTRATION_RATE_LIMIT_CIRCUIT.wait()
if _pair_is_expired(pair):
if recovery_probe:
REGISTRATION_RATE_LIMIT_CIRCUIT.release_probe(recovery_probe)
recovery_probe = False
continue
task_id = metrics.next_registration_task()
log(
format_user_registration_event(
"started",
task_id=task_id,
remaining=max(TARGET - metrics.success_count, 0) if TARGET else None,
)
)
try:
ok = await asyncio.wait_for(
_consume_pair(
browser,
physical_sem,
pair,
metrics,
task_id=task_id,
recovery_probe=recovery_probe,
),
timeout=C_CONSUME_TIMEOUT,
)
if ok:
metrics.pair_consumed_ok += 1
else:
metrics.pair_consumed_fail += 1
except asyncio.TimeoutError:
metrics.pair_consumed_fail += 1
log(format_user_registration_event("failed", task_id=task_id))
except asyncio.CancelledError:
raise
except Exception as e:
if task_id is not None:
log(format_user_registration_event("failed", task_id=task_id))
debug_log(f'[C] {wid} err: {sanitize_terminal_error(e)}')
metrics.pair_consumed_fail += 1
finally:
if recovery_probe:
# 覆盖等待 pair、获取物理许可或其他边界处的取消/异常。
# 已成功或已延期的 probe 在这里会是幂等 no-op。
REGISTRATION_RATE_LIMIT_CIRCUIT.defer_probe(recovery_probe)
await asyncio.sleep(0.2)
# ──────────────────────────────────────────────
# 只读监控
# ──────────────────────────────────────────────
async def monitor(inventory, sems, metrics, interval=8):
"""定期输出系统状态。"""
while not STOP.is_set():
await asyncio.sleep(interval)
if REGISTER_LOG_MODE == "debug":
log(metrics.snapshot(inventory, sems))
if TARGET and metrics.success_count >= TARGET:
log(f'[*] 已达目标 {TARGET} 个,停止。'); STOP.set()
# ──────────────────────────────────────────────
# 主入口
# ──────────────────────────────────────────────
async def main():
global TARGET, _c_hot_page_pool_size
max_mem_arg = None
for i, arg in enumerate(sys.argv[1:], 1):
if arg == '--max-mem' and i + 1 < len(sys.argv):
max_mem_arg = sys.argv[i + 1]
elif arg == '--target' and i + 1 < len(sys.argv):
TARGET = int(sys.argv[i + 1])
resources = get_system_resources(max_mem_arg)
cpu = resources['cpu']
capacity_profile = load_capacity_profile()
# 自动派生容量
physical_cap, s_workers, p_workers, c_workers = derive_capacity(
cpu,
resources['max_mem'],
profile_physical_cap=capacity_profile.get("physical_cap"),
)
p_send_cap = P_SEND_CAP if P_SEND_CAP > 0 else 0
admission_watermarks = derive_admission_watermarks(physical_cap)
_c_hot_page_pool_size = derive_c_hot_page_pool_size(physical_cap, c_workers)
# 校验邮箱模式配置
if EMAIL_MODE not in ('tempmail', 'custom'):
log("[!] 配置错误EMAIL_MODE 应为 tempmail 或 custom"); return 2
if EMAIL_MODE == 'custom' and not EMAIL_DOMAIN:
log("[!] 配置错误custom 模式需在 .env 设置 EMAIL_DOMAIN"); return 2
debug_log("=" * 50)
debug_log(f" Grok Free Register (CSP Architecture)")
debug_log(f" CPU: {cpu} cores Memory: {resources['available_mem']}/{resources['total_mem']}MB")
debug_log(f" MaxMemForAuto: {resources['max_mem']}MB MemReserve: {MIN_FREE_MEM_MB}MB PhysicalMemBudget: {PHYSICAL_MEM_MB}MB")
if capacity_profile:
debug_log(f" CapacityProfile: {CAPACITY_PROFILE} physical_cap={capacity_profile.get('physical_cap')}")
debug_log(f" EmailMode: {EMAIL_MODE}")
debug_log(f" Physical_Sem={physical_cap} T_Slot={T_SLOT_CAP} Q_Slot={Q_SLOT_CAP} Q_Pending={Q_PENDING_CAP}")
debug_log(
f" Admission: T_LOW/HIGH={admission_watermarks['t_low']}/{admission_watermarks['t_high']} "
f"Q_LOW/HIGH={admission_watermarks['q_low']}/{admission_watermarks['q_high']}"
)
debug_log(f" P_BatchMax={P_BATCH_MAX} P_Send_Sem={'disabled' if p_send_cap == 0 else p_send_cap}")
debug_log(
f" C_HotPagePool={'on' if C_HOT_PAGE_POOL else 'off'}"
f" size={_c_hot_page_pool_size if C_HOT_PAGE_POOL else 0}"
f" setCookieViaRequest={'on' if C_SET_COOKIE_VIA_REQUEST else 'off'}"
)
debug_log(f" Workers: S={s_workers} P={p_workers} C={c_workers}")
debug_log(
f" Timeouts: Solver={SOLVER_HARD_TIMEOUT}s SolverCleanup={SOLVER_CLEANUP_TIMEOUT}s "
f"P_Request={P_REQUEST_TIMEOUT}s C_Consume={C_CONSUME_TIMEOUT}s"
)
debug_log(
f" SolverMouseClick: retries={SOLVER_MOUSE_CLICK_RETRIES} "
f"interval={SOLVER_MOUSE_CLICK_INTERVAL_MS}ms"
)
if TARGET:
debug_log(f" Target: {TARGET}")
reg_proxy = register_proxy_url() or "(direct)"
debug_log(f" RegisterProxy: {reg_proxy}")
debug_log("=" * 50)
# 上次异常退出可能残留 CloakBrowser先收尸再 launch避免叠 34 份吃光内存
reaped = await asyncio.to_thread(reap_orphan_register_browsers)
if reaped:
log(f"[*] 已回收 {reaped} 个残留注册浏览器进程")
# CF prewarm via external FlareSolverr (:8191) — separate from REGISTER_PROXY egress
prewarm = await asyncio.to_thread(prewarm_clearance)
log(format_prewarm_log(prewarm))
await fetch_config()
async with async_playwright() as pw:
browser = await pw.chromium.launch(**_launch_options())
debug_log(f'[*] Browser launched (proxy={reg_proxy})')
# CSP 组件
metrics = Metrics()
inventory = Inventory(metrics=metrics)
admission_gate = AdmissionGate(
inventory,
t_low=admission_watermarks["t_low"],
t_high=admission_watermarks["t_high"],
q_low=admission_watermarks["q_low"],
q_high=admission_watermarks["q_high"],
)
physical_sem = asyncio.Semaphore(physical_cap)
p_send_sem = asyncio.Semaphore(p_send_cap) if p_send_cap > 0 else None
t_slot_sem = asyncio.Semaphore(T_SLOT_CAP)
q_slot_sem = asyncio.Semaphore(Q_SLOT_CAP)
q_pending_sem = asyncio.Semaphore(Q_PENDING_CAP)
sems = {
'physical': physical_sem,
't_slot': t_slot_sem,
'q_slot': q_slot_sem,
'q_pending': q_pending_sem,
'admission': admission_gate,
}
if p_send_sem is not None:
sems['p_send'] = p_send_sem
tasks = []
# S_Workers
for i in range(s_workers):
tasks.append(asyncio.create_task(
s_worker(i, browser, inventory, physical_sem, t_slot_sem, metrics, admission_gate)
))
# P_Workers
for i in range(p_workers):
tasks.append(asyncio.create_task(
p_worker(
i,
browser,
inventory,
physical_sem,
q_pending_sem,
q_slot_sem,
metrics,
admission_gate,
p_send_sem,
P_BATCH_MAX,
)
))
# C_Workers
for i in range(c_workers):
tasks.append(asyncio.create_task(
c_worker(i, browser, inventory, physical_sem, metrics, admission_gate)
))
# Monitor
tasks.append(asyncio.create_task(monitor(inventory, sems, metrics)))
debug_log(f'[*] CSP up: S={s_workers} P={p_workers} C={c_workers} workers')
log(
format_user_registration_event(
"service_started",
remaining=max(TARGET - metrics.success_count, 0) if TARGET else None,
)
)
try:
await asyncio.gather(*tasks)
except (KeyboardInterrupt, asyncio.CancelledError):
pass
finally:
for t in tasks:
t.cancel()
with suppress(Exception):
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=3)
await _close_c_hot_page_pool()
await _close_browser_hard(browser, timeout=5.0)
# 双保险close 后仍扫一遍孤儿
leftover = await asyncio.to_thread(reap_orphan_register_browsers)
if leftover:
log(f"[*] 退出时补杀 {leftover} 个残留浏览器")
log(format_user_registration_event("stopped", count=metrics.success_count))
return 0
if __name__ == "__main__":
try:
REGISTER_LOG_MODE = resolve_register_log_mode(sys.argv[1:])
except ValueError:
log("[!] 配置错误REGISTER_LOG_MODE 应为 user 或 debug")
raise SystemExit(2)
try:
exit_code = asyncio.run(main())
except ValueError as exc:
log(f"[!] 配置错误:{sanitize_terminal_error(exc)}")
exit_code = 2
except KeyboardInterrupt:
# asyncio.run 外层再兜一层;收尸防止 kill -2 叠进程
try:
n = reap_orphan_register_browsers()
if n:
print(f"[*] 中断收尸 {n} 个浏览器", flush=True)
except Exception:
pass
exit_code = 130
raise SystemExit(exit_code)