""" 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)