Files
grok-keygen-new/grok_register/register.py
T
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

"""
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,避免叠 3–4 份吃光内存
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)