#!/usr/bin/env python # -*- coding: utf-8 -*- """ Grok 注册机 - TTK GUI 版本 整合 DrissionPage_example.py, openai_register.py, batch_open_nsfw.py """ import tkinter as tk from tkinter import ttk, messagebox, scrolledtext import threading import datetime import time import os import sys import queue import secrets import struct import random import re import string import json import imaplib import email as email_lib from datetime import timezone from email.utils import parsedate_to_datetime from DrissionPage import Chromium, ChromiumOptions from DrissionPage.errors import PageDisconnectedError from curl_cffi import requests CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") DEFAULT_CONFIG = { "duckmail_api_key": "", "cloudflare_api_base": "", "cloudflare_api_key": "", "cloudflare_auth_mode": "bearer", "cloudflare_path_domains": "/domains", "cloudflare_path_accounts": "/accounts", "cloudflare_path_token": "/token", "cloudflare_path_messages": "/messages", "proxy": "http://127.0.0.1:7890", "enable_nsfw": True, "register_count": 1, "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", "grok2api_auto_add_local": True, "grok2api_local_token_file": "", "grok2api_pool_name": "ssoBasic", "grok2api_auto_add_remote": False, "grok2api_remote_base": "", "grok2api_remote_app_key": "", "register_threads": 1, "thread_start_interval": 0.8, "show_tutorial_on_start": True, "cloudmail_url": "", "cloudmail_admin_email": "", "cloudmail_password": "", "cpa_gui_close_mint_browser": True, "browser_headless": "auto", "browser_prefer": "auto", "browser_path": "", "hotmail_accounts_file": "mail_credentials.txt", "hotmail_alias_mode": "random", "hotmail_alias_random_length": 8, "hotmail_alias_random_max_attempts": 200, "hotmail_max_aliases_per_account": 5, "hotmail_poll_interval": 5, "hotmail_recent_seconds": 900, "hotmail_imap_hosts": "outlook.office365.com,imap-mail.outlook.com", "hotmail_imap_last_n": 30, "hotmail_require_recipient_match": True, } config = DEFAULT_CONFIG.copy() _cf_domain_index = 0 # CloudMail 公开 token 单例(多线程共享,避免并发覆盖) _cloudmail_public_token = None _cloudmail_public_token_lock = threading.Lock() _cpa_gui_export_lock = threading.Lock() _hotmail_accounts_cache = None _hotmail_accounts_mtime = None _hotmail_accounts_lock = threading.Lock() _hotmail_selection_lock = threading.Lock() _hotmail_reserved_aliases = set() _hotmail_token_map = {} _hotmail_refresh_locks = {} _hotmail_refresh_locks_lock = threading.Lock() # ── 邮箱追踪 ── _EMAILS_USED_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails_used.txt") _EMAILS_ERROR_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails_error.txt") _email_track_lock = threading.Lock() def mark_used(email: str, password: str = ""): """记录成功注册的邮箱,防止重复使用。""" with _email_track_lock: with open(_EMAILS_USED_FILE, "a", encoding="utf-8") as f: f.write(f"{email}----{password}----ok\n") try: _hotmail_release_alias(email) except Exception: pass def mark_error(email: str, password: str = "", reason: str = ""): """记录失败邮箱及原因,避免重试烂邮箱。""" with _email_track_lock: with open(_EMAILS_ERROR_FILE, "a", encoding="utf-8") as f: f.write(f"{email}----{password}----{reason}\n") try: _hotmail_release_alias(email) except Exception: pass def is_email_used(email: str) -> bool: """检查邮箱是否已被使用或标记为失败。""" email_lower = email.strip().lower() for fpath in (_EMAILS_USED_FILE, _EMAILS_ERROR_FILE): if os.path.exists(fpath): with open(fpath, encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#"): parts = line.split("----") if parts and parts[0].strip().lower() == email_lower: return True return False # ── 页面状态快照 ── # ── CLI / batch performance knobs (register_cli may mutate) ── PERF_FLAGS = { "fast": False, # scale down human_sleep "sleep_scale": 1.0, # multiply all human_sleep means "skip_debug_io": False, # skip dump_state / take_screenshot "cookie_snapshot": True, # save_cookies_snapshot "async_side_effects": True, # grok2api / cookie snapshot in background "browser_reuse": True, # clear_session instead of quit between accounts "browser_recycle_every": 25, # full quit+recreate after N successful reuses } _side_effect_pool = None def _get_side_effect_pool(): global _side_effect_pool if _side_effect_pool is None: from concurrent.futures import ThreadPoolExecutor _side_effect_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="sidefx") return _side_effect_pool def configure_perf(**kwargs): """Update PERF_FLAGS from CLI. Unknown keys ignored.""" for k, v in kwargs.items(): if k in PERF_FLAGS: PERF_FLAGS[k] = v _SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots") def dump_state(page, tag: str = ""): """打印当前页面状态:URL、可见按钮文本、输入框类型。""" if PERF_FLAGS.get("skip_debug_io"): return try: info = page.run_js(""" const btns = [...document.querySelectorAll('button')] .map(b => (b.innerText || '').trim()) .filter(t => t) .slice(0, 20); const inputs = [...document.querySelectorAll('input,textarea')] .map(i => (i.type || 'text') + '/' + (i.placeholder || i.name || i.getAttribute('data-testid') || '')) .slice(0, 15); return {url: location.href, title: document.title, btns: btns, inputs: inputs}; """) if not info: print(f" [state:{tag}] page context not ready (None)") return print(f" [state:{tag}] url: {info.get('url', '?')}") print(f" [state:{tag}] btns: {info.get('btns', [])}") print(f" [state:{tag}] inputs: {info.get('inputs', [])}") except Exception as e: print(f" [state:{tag}] dump_state err: {e}") def take_screenshot(page, tag: str = ""): """捕获当前页面截图并保存到 screenshots/ 目录。""" if PERF_FLAGS.get("skip_debug_io"): return try: os.makedirs(_SCREENSHOT_DIR, exist_ok=True) ts = datetime.datetime.now().strftime("%H%M%S") path = os.path.join(_SCREENSHOT_DIR, f"{ts}_{tag}.png") page.get_screenshot(path=path) print(f" [screenshot] saved: {path}") except Exception as e: print(f" [screenshot] err: {e}") # ── 超时守卫 ── REGISTER_TIMEOUT = 180 # 单次注册总超时(秒) class TimeoutError(Exception): pass def check_timeout(start_time: float): """检查是否超过总超时时间。""" elapsed = time.time() - start_time if elapsed > REGISTER_TIMEOUT: raise TimeoutError(f"注册超时 ({REGISTER_TIMEOUT}s, 已用 {elapsed:.0f}s)") # ── 全量 cookie 保存 ── _COOKIE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies", "grok") def save_cookies_snapshot(page, tag: str = "", email: str = ""): """保存当前浏览器上下文的全量 cookie 快照。""" if not PERF_FLAGS.get("cookie_snapshot", True): return try: browser = _get_browser() if not browser: return os.makedirs(_COOKIE_DIR, exist_ok=True) ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") cookies = browser.cookies() data = { "ts": ts, "tag": tag, "email": email, "url": page.url if page else "", "cookies": cookies, } path = os.path.join(_COOKIE_DIR, f"full_{ts}_{tag}.json") with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f" [cookies] saved: {path} ({len(cookies)} cookies)") except Exception as e: print(f" [cookies] save err: {e}") # ── .env 加载 ── def load_env(): """从 .env 文件加载环境变量(零依赖)。 只在 os.environ 中尚未设置该 KEY 时填入(真实环境变量优先)。 """ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") if not os.path.isfile(env_path): return try: with open(env_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") key = key.strip() val = val.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = val except Exception: pass class RegistrationCancelled(Exception): pass def load_config(): load_env() global config if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: loaded = json.load(f) # Allow "// comment" keys in config.example.json / config.json templates. if isinstance(loaded, dict): loaded = { k: v for k, v in loaded.items() if not (isinstance(k, str) and (k.startswith("//") or k.startswith("#"))) } config = {**DEFAULT_CONFIG, **loaded} except Exception: config = DEFAULT_CONFIG.copy() return config def save_config(): try: with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=4, ensure_ascii=False) except Exception as e: print(f"保存配置失败: {e}") def ensure_stable_python_runtime(): if sys.version_info < (3, 14) or os.environ.get("DPE_REEXEC_DONE") == "1": return local_app_data = os.environ.get("LOCALAPPDATA", "") candidates = [ os.path.join(local_app_data, "Programs", "Python", "Python312", "python.exe"), os.path.join(local_app_data, "Programs", "Python", "Python313", "python.exe"), ] current_python = os.path.normcase(os.path.abspath(sys.executable)) for candidate in candidates: if not os.path.isfile(candidate): continue if os.path.normcase(os.path.abspath(candidate)) == current_python: return print( f"[*] 检测到 Python {sys.version.split()[0]},自动切换到更稳定的解释器: {candidate}" ) env = os.environ.copy() env["DPE_REEXEC_DONE"] = "1" os.execve(candidate, [candidate, os.path.abspath(__file__), *sys.argv[1:]], env) def warn_runtime_compatibility(): if sys.version_info >= (3, 14): print( "[提示] 当前 Python 为 3.14+;若出现 Mail.tm TLS 异常,建议改用 Python 3.12 或 3.13。" ) ensure_stable_python_runtime() warn_runtime_compatibility() load_config() EXTENSION_PATH = os.path.abspath( os.path.join(os.path.dirname(__file__), "turnstilePatch") ) DUCKMAIL_API_BASE = "https://api.duckmail.sbs" def get_proxies(): proxy = config.get("proxy", "") if proxy: return {"http": proxy, "https": proxy} return {} def get_duckmail_api_key(): return config.get("duckmail_api_key", "") def get_cloudflare_api_base(): return str(config.get("cloudflare_api_base", "") or "").rstrip("/") def get_cloudflare_api_key(): return config.get("cloudflare_api_key", "") def get_cloudflare_auth_mode(): return str(config.get("cloudflare_auth_mode", "bearer") or "bearer").lower() def get_cloudflare_path(key, default_path): raw = str(config.get(key, default_path) or default_path).strip() if not raw.startswith("/"): raw = "/" + raw return raw def cloudflare_build_headers(content_type=False): headers = {"Content-Type": "application/json"} if content_type else {} key = get_cloudflare_api_key() mode = get_cloudflare_auth_mode() if key: if mode == "x-api-key": headers["X-API-Key"] = key elif mode != "none": headers["Authorization"] = f"Bearer {key}" return headers def cloudflare_apply_auth_params(params=None): merged = dict(params or {}) key = get_cloudflare_api_key() mode = get_cloudflare_auth_mode() if key and mode == "query-key": merged["key"] = key return merged def _pick_list_payload(data): if isinstance(data, list): return data if isinstance(data, dict): if isinstance(data.get("results"), list): return data.get("results") if isinstance(data.get("hydra:member"), list): return data.get("hydra:member") if isinstance(data.get("data"), list): return data.get("data") if isinstance(data.get("messages"), list): return data.get("messages") if isinstance(data.get("data"), dict): nested = data.get("data") if isinstance(nested.get("messages"), list): return nested.get("messages") return [] def cloudflare_create_temp_address(api_base): """适配 cloudflare_temp_email v1.8.x: POST /api/new_address -> {address,jwt}""" global _cf_domain_index url = f"{api_base}/api/new_address" payload = {} try: # 在多个域名之间轮换,降低单域偶发不收件导致的失败率 domains = [x.strip() for x in re.split(r"[,,\s]+", str(config.get("defaultDomains", "") or "")) if x.strip()] if domains: payload["domain"] = domains[_cf_domain_index % len(domains)] _cf_domain_index += 1 except Exception: pass resp = http_post(url, json=payload, headers={"Content-Type": "application/json"}) resp.raise_for_status() try: data = resp.json() except Exception: raise Exception(f"Cloudflare /api/new_address 返回非JSON: {resp.text[:300]}") address = data.get("address") jwt = data.get("jwt") if not address or not jwt: raise Exception(f"Cloudflare /api/new_address 缺少 address/jwt: {data}") return address, jwt def get_user_agent(): return config.get( "user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", ) def resolve_grok2api_local_token_file(): configured = str(config.get("grok2api_local_token_file", "") or "").strip() if configured: return configured return r"D:\注册机\3255d5ee6e702db9220a897df64635a1ec9df644\vendor\grok2api\data\token.json" def _normalize_sso_token(raw_token): token = str(raw_token or "").strip() if token.startswith("sso="): token = token[4:] return token def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None): token = _normalize_sso_token(raw_token) if not token: return False token_file = resolve_grok2api_local_token_file() pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() if not pool_name: pool_name = "ssoBasic" os.makedirs(os.path.dirname(token_file), exist_ok=True) data = {} if os.path.exists(token_file): try: with open(token_file, "r", encoding="utf-8") as f: data = json.load(f) or {} except Exception: data = {} if not isinstance(data, dict): data = {} pool = data.get(pool_name) if not isinstance(pool, list): pool = [] existing = set() for item in pool: if isinstance(item, str): existing.add(_normalize_sso_token(item)) elif isinstance(item, dict): existing.add(_normalize_sso_token(item.get("token", ""))) if token in existing: if log_callback: log_callback(f"[*] grok2api 本地池已存在 token: {pool_name}") return True entry = {"token": token, "tags": ["auto-register"], "note": email} pool.append(entry) data[pool_name] = pool with open(token_file, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) if log_callback: log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})") return True def add_token_to_grok2api_remote_pool(raw_token, email="", log_callback=None): token = _normalize_sso_token(raw_token) if not token: return False base = str(config.get("grok2api_remote_base", "") or "").strip().rstrip("/") app_key = str(os.environ.get("GROK2API_APP_KEY") or config.get("grok2api_remote_app_key", "") or "").strip() pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() or "ssoBasic" if not base or not app_key: if log_callback: log_callback("[Debug] grok2api 远端未配置 base/app_key,跳过") return False headers = {"Content-Type": "application/json"} query = {"app_key": app_key, "auto_nsfw": "true"} pool_map = {"ssoBasic": "basic", "ssoSuper": "super"} remote_pool = pool_map.get(pool_name, "basic") # 优先使用 add 接口,避免全量覆盖远端池 try: add_payload = {"tokens": [token], "pool": remote_pool, "tags": ["auto-register"]} resp_add = http_post( f"{base}/tokens/add", headers=headers, params=query, json=add_payload, timeout=8, proxies={}, ) resp_add.raise_for_status() if log_callback: log_callback(f"[+] 已写入 grok2api 远端池: {pool_name} ({base}/tokens/add)") return True except Exception as add_exc: if log_callback: log_callback(f"[Debug] /tokens/add 写入失败,尝试 /tokens 全量模式: {add_exc}") # 兜底:旧版全量保存接口 current = {} try: resp = http_get(f"{base}/tokens", headers=headers, params=query, timeout=6, proxies={}) if resp.status_code == 200: payload = resp.json() current = payload.get("tokens", {}) if isinstance(payload, dict) else {} except Exception: current = {} if not isinstance(current, dict): current = {} pool = current.get(pool_name) if not isinstance(pool, list): pool = [] existing = set() for item in pool: if isinstance(item, str): existing.add(_normalize_sso_token(item)) elif isinstance(item, dict): existing.add(_normalize_sso_token(item.get("token", ""))) if token not in existing: pool.append({"token": token, "tags": ["auto-register"], "note": email}) current[pool_name] = pool resp2 = http_post(f"{base}/tokens", headers=headers, params=query, json=current, timeout=8, proxies={}) resp2.raise_for_status() if log_callback: log_callback(f"[+] 已写入 grok2api 远端池: {pool_name} ({base}/tokens)") return True def _add_token_to_grok2api_pools_sync(raw_token, email="", log_callback=None): # SSO 账本只写 accounts_cli.txt;不再本地备份 tokens/grok/ if config.get("grok2api_auto_add_local", True): try: add_token_to_grok2api_local_pool(raw_token, email=email, log_callback=log_callback) except Exception as exc: if log_callback: log_callback(f"[Debug] 写入 grok2api 本地池失败: {exc}") if config.get("grok2api_auto_add_remote", False): try: add_token_to_grok2api_remote_pool(raw_token, email=email, log_callback=log_callback) except Exception as exc: if log_callback: log_callback(f"[Debug] 写入 grok2api 远端池失败: {exc}") def add_token_to_grok2api_pools(raw_token, email="", log_callback=None): """Push SSO into grok2api pools. Async by default so register path never blocks on dead :8000.""" if PERF_FLAGS.get("async_side_effects", True): def _job(): try: _add_token_to_grok2api_pools_sync(raw_token, email=email, log_callback=log_callback) except Exception as exc: if log_callback: log_callback(f"[Debug] grok2api side-effect 异常: {exc}") try: _get_side_effect_pool().submit(_job) if log_callback: log_callback("[*] grok2api 池写入已异步提交") return except Exception as exc: if log_callback: log_callback(f"[Debug] 异步提交失败,同步写入: {exc}") _add_token_to_grok2api_pools_sync(raw_token, email=email, log_callback=log_callback) # Headed flags from grok-register.zip (ReinerBRO): no --disable-images, no AutomationControlled. # Images must stay ON or Turnstile iframe often fails to paint. CHROMIUM_BASE_FLAGS = [ "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", ] # Headless / Linux-server extras only. --disable-software-rasterizer blanks CF on headed. CHROMIUM_HEADLESS_EXTRA_FLAGS = [ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--disable-software-rasterizer", "--mute-audio", ] # Back-compat alias used by older call sites / register_cli patches CHROMIUM_SLIM_FLAGS = CHROMIUM_BASE_FLAGS + CHROMIUM_HEADLESS_EXTRA_FLAGS # Headless / display helpers (Linux servers without desktop) _HEADLESS_HINT_SHOWN = False _HEADLESS_HINT_LOCK = threading.Lock() def has_display() -> bool: """Return True if a GUI display is likely available for Chromium. - Windows: always True (Win32 GUI subsystem). - Linux/macOS: need DISPLAY or WAYLAND_DISPLAY (Xvfb counts as DISPLAY). """ if sys.platform == "win32": return True display = (os.environ.get("DISPLAY") or "").strip() wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip() return bool(display or wayland) def _truthy_headless(val) -> bool | None: """Parse headless preference. True/False explicit; None means auto.""" if val is None: return None if isinstance(val, bool): return val s = str(val).strip().lower() if s in ("", "auto", "default", "detect"): return None if s in ("1", "true", "yes", "on", "headless"): return True if s in ("0", "false", "no", "off", "headed", "gui"): return False return None def resolve_headless_preference(preference=None) -> bool | None: """Resolve headless preference from arg → env → config → auto. Returns: True = force headless False = force headed None = auto (headless when no DISPLAY) """ if preference is not None: parsed = _truthy_headless(preference) if parsed is not None or str(preference).strip().lower() in ("", "auto", "default", "detect"): return parsed env_raw = (os.environ.get("BROWSER_HEADLESS") or os.environ.get("GROK_HEADLESS") or "").strip() if env_raw: return _truthy_headless(env_raw) try: cfg_val = config.get("browser_headless", "auto") if isinstance(config, dict) else "auto" except Exception: cfg_val = "auto" return _truthy_headless(cfg_val) def should_use_headless(preference=None, *, log_hint: bool = True) -> bool: """Decide whether Chromium should start headless. Auto (default): headless when no DISPLAY/WAYLAND_DISPLAY. Force headed without display is impossible — still headless + warn. """ pref = resolve_headless_preference(preference) display_ok = has_display() if pref is True: if log_hint: log_headless_mode(forced=True, auto=False, display_ok=display_ok) return True if pref is False: if display_ok: return False # Cannot start headed without display — force headless if log_hint: log_headless_mode(forced=True, auto=True, display_ok=False, wanted_headed=True) return True # auto use = not display_ok if use and log_hint: log_headless_mode(forced=False, auto=True, display_ok=False) return use def log_headless_mode( *, forced: bool = False, auto: bool = False, display_ok: bool = False, wanted_headed: bool = False, ) -> None: """Print one-shot guidance for headless / Xvfb on servers.""" global _HEADLESS_HINT_SHOWN with _HEADLESS_HINT_LOCK: if _HEADLESS_HINT_SHOWN: return _HEADLESS_HINT_SHOWN = True disp = (os.environ.get("DISPLAY") or "").strip() or "(unset)" wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip() or "(unset)" if wanted_headed and not display_ok: print( f"[!] browser_headless=false/--headed 但无图形界面 " f"(DISPLAY={disp}, WAYLAND_DISPLAY={wayland}),仍强制 headless,否则无法启动。", flush=True, ) elif auto and not display_ok: print( f"[*] 无图形界面 (DISPLAY={disp}),已自动启用 --headless=new。", flush=True, ) elif forced: print("[*] 已强制启用 headless 浏览器。", flush=True) else: return print( " 提示: headless 更容易被 Cloudflare/Turnstile 拦截;无头服务器推荐虚拟显示:\n" " sudo apt install -y xvfb\n" " xvfb-run -a uv run python -u register_cli.py --extra 1 --threads 1\n" " 或常驻: Xvfb :99 -screen 0 1920x1080x24 & export DISPLAY=:99\n" " 强制无头: config browser_headless=true / env BROWSER_HEADLESS=1 / CLI --headless\n" " 强制有头: config browser_headless=false / CLI --headed(需 DISPLAY 或 xvfb-run)", flush=True, ) def apply_headless_to_options(options, headless: bool = True) -> None: """Apply headless flags to ChromiumOptions (DrissionPage).""" if not headless: try: options.headless(False) except Exception: pass return try: options.headless(True) except Exception: try: options.set_argument("--headless=new") except Exception: try: options.set_argument("--headless") except Exception: pass # Extra flags commonly needed on Linux servers for flag in ("--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"): try: options.set_argument(flag) except Exception: pass # Browser discovery order is decided by resolve_browser_path(): # - default (auto): prefer Chromium so turnstilePatch can load (--load-extension) # - Google Chrome blocks unpacked extensions; CDP stealth alone often fails CF on servers # - config browser_prefer: auto | chromium | chrome # - config browser_path: absolute override def _windows_browser_candidates() -> tuple[str, ...]: """Windows Chrome/Chromium paths (Program Files + common Playwright installs).""" if sys.platform != "win32": return () out: list[str] = [] local = os.environ.get("LOCALAPPDATA") or "" pf = os.environ.get("PROGRAMFILES") or r"C:\Program Files" pf86 = os.environ.get("PROGRAMFILES(X86)") or r"C:\Program Files (x86)" # Prefer Chromium-like (can load unpacked extensions) over Google Chrome if local: # ms-playwright chromium-* / chrome-win64 try: pw = os.path.join(local, "ms-playwright") if os.path.isdir(pw): for name in sorted(os.listdir(pw), reverse=True): if not name.lower().startswith("chromium"): continue cand = os.path.join(pw, name, "chrome-win64", "chrome.exe") if os.path.isfile(cand): out.append(cand) except Exception: pass out.append(os.path.join(local, "Chromium", "Application", "chrome.exe")) out.append(os.path.join(local, "Google", "Chrome", "Application", "chrome.exe")) out.append(os.path.join(pf, "Chromium", "Application", "chrome.exe")) out.append(os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe")) out.append(os.path.join(pf86, "Google", "Chrome", "Application", "chrome.exe")) # dedupe preserve order seen: set[str] = set() uniq: list[str] = [] for p in out: key = p.lower() if key in seen: continue seen.add(key) uniq.append(p) return tuple(uniq) _WIN_BROWSERS = _windows_browser_candidates() def _playwright_chromium_candidates() -> tuple[str, ...]: """Prefer Playwright's Chromium (no snap AppArmor; ReinerBRO/grok-register path).""" roots = [ os.path.expanduser("~/.cache/ms-playwright"), os.path.join(os.environ.get("LOCALAPPDATA") or "", "ms-playwright"), ] out: list[str] = [] for root in roots: if not root or not os.path.isdir(root): continue try: for name in sorted(os.listdir(root), reverse=True): if not name.lower().startswith("chromium"): continue # linux: chromium-XXXX/chrome-linux64/chrome # win: chromium-XXXX/chrome-win64/chrome.exe for rel in ( ("chrome-linux64", "chrome"), ("chrome-linux", "chrome"), ("chrome-win64", "chrome.exe"), ("chrome-win", "chrome.exe"), ): cand = os.path.join(root, name, *rel) if os.path.isfile(cand): out.append(cand) except Exception: pass return tuple(out) # Chromium first (turnstilePatch / --load-extension). # Playwright Chromium preferred over distro/snap (AppArmor + broken wrapper issues). # On Windows, Playwright chromium-* paths contain "chromium" and pass _is_google_chrome_path=False. CHROMIUM_CANDIDATES = ( _playwright_chromium_candidates() + ( "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/lib/chromium/chromium", "/usr/lib/chromium-browser/chromium-browser", "/snap/bin/chromium", # keep as /snap/bin/chromium — do NOT resolve to /usr/bin/snap ) + tuple(p for p in _WIN_BROWSERS if "google" not in p.lower() or "chromium" in p.lower()) ) CHROME_CANDIDATES = ( "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", ) + tuple(p for p in _WIN_BROWSERS if "google" in p.lower() and "chromium" not in p.lower()) # Legacy flat list (tests / external callers) BROWSER_CANDIDATES = CHROMIUM_CANDIDATES + CHROME_CANDIDATES # Minimal CDP stealth only. Aggressive patches (chrome.runtime delete, permissions # monkeypatch, fake plugins/languages, postMessage spam) caused xAI UI to show # [permission_denied] HTTP 403 and blocked signup after the full turnstilePatch port. STEALTH_INIT_JS = r""" (function () { if (window.__grokStealthApplied) return; window.__grokStealthApplied = true; try { Object.defineProperty(navigator, "webdriver", { get: function () { return false; }, configurable: true, }); } catch (e) {} try { // Prefer prototype-level override used by many detectors var desc = Object.getOwnPropertyDescriptor(Navigator.prototype, "webdriver"); if (!desc || desc.configurable) { Object.defineProperty(Navigator.prototype, "webdriver", { get: function () { return false; }, configurable: true, }); } } catch (e) {} })(); """ # Paths that look like snap host / shell wrappers, not a browser binary. _INVALID_BROWSER_BASENAMES = frozenset( { "snap", "snap-confine", "snapd", "env", "bash", "sh", "python", "python3", "uv", } ) def _is_google_chrome_path(path: str | None) -> bool: if not path: return False base = os.path.basename(path) # /usr/bin/snap is NOT chrome if base in _INVALID_BROWSER_BASENAMES: return False return "chrome" in base and "chromium" not in path.lower() def _looks_like_browser_path(path: str | None) -> bool: """Reject snap host binary etc. Accept chromium/chrome paths only.""" if not path: return False if not (os.path.isfile(path) or os.path.islink(path)): return False base = os.path.basename(path) if base in _INVALID_BROWSER_BASENAMES: return False low = path.replace("\\", "/").lower() # Must look like a browser, not generic "snap" if "chromium" in low or "chrome" in low: return True return False def _normalize_browser_path(path: str | None) -> str | None: """Return a usable browser executable path. Critical: /snap/bin/chromium often realpath()'s to /usr/bin/snap. Using /usr/bin/snap as browser_path makes DrissionPage fail to connect. Keep the original /snap/bin/chromium wrapper path instead. """ if not path: return None path = str(path).strip() if not path: return None if not (os.path.isfile(path) or os.path.islink(path) or os.path.exists(path)): return None # Prefer original path if already valid (esp. /snap/bin/chromium) if _looks_like_browser_path(path): # If under /snap/bin/, never realpath — that collapses to /usr/bin/snap if path.startswith("/snap/bin/"): return path try: real = os.path.realpath(path) except Exception: real = path if _looks_like_browser_path(real): return real # realpath bad (e.g. snap) but original ok return path # realpath might still help for normal symlinks try: real = os.path.realpath(path) except Exception: real = "" if real and _looks_like_browser_path(real): return real return None def _first_existing_browser(candidates) -> str | None: for cand in candidates: if not cand: continue norm = _normalize_browser_path(cand) if norm: return norm return None def resolve_browser_path(): """Zip style: only return path if config/env override exists. browser_prefer=auto/system → None (DrissionPage system Chrome). """ override = str((config.get("browser_path") if isinstance(config, dict) else "") or "").strip() if override: norm = _normalize_browser_path(override) if norm: return norm print(f" [browser] ignore invalid browser_path={override!r}", flush=True) for env_key in ("BROWSER_PATH", "CHROME_PATH", "CHROMIUM_PATH"): env_path = (os.environ.get(env_key) or "").strip() if not env_path: continue norm = _normalize_browser_path(env_path) if norm: return norm print(f" [browser] ignore invalid {env_key}={env_path!r}", flush=True) prefer = str((config.get("browser_prefer") if isinstance(config, dict) else "") or "auto").strip().lower() if prefer in ("", "default", "detect", "system"): prefer = "auto" env_pref = (os.environ.get("BROWSER_PREFER") or "").strip().lower() if env_pref: prefer = env_pref if prefer in ("auto", "system"): return None if prefer in ("chrome", "google-chrome", "google"): order = list(CHROME_CANDIDATES) + list(CHROMIUM_CANDIDATES) elif prefer in ("chromium", "chromeium"): order = list(CHROMIUM_CANDIDATES) + list(CHROME_CANDIDATES) else: return None return _first_existing_browser(order) def apply_stealth_patches(page=None, log_callback=None) -> bool: """Minimal anti-automation patch via CDP (Chrome cannot load unpacked extensions). Keep this conservative: over-patching (chrome.runtime / permissions / plugins) previously produced xAI [permission_denied] HTTP 403 on signup APIs. config.stealth_mode: off | minimal(default) | full """ # zip 默认不注入 CDP stealth(靠扩展);未配置时也走 off,避免和 content.js 叠指纹 mode = str((config.get("stealth_mode") if isinstance(config, dict) else "") or "off").strip().lower() if mode in ("0", "false", "off", "none", "disabled", ""): return False page = page if page is not None else _get_page() if page is None: return False script = STEALTH_INIT_JS if mode in ("full", "aggressive", "legacy"): # Legacy full patch kept for optional experiment only script = STEALTH_INIT_JS # still minimal unless we reintroduce full later if getattr(page, "_grok_stealth_applied", False): try: page.run_js(script) except Exception: pass return True ok = False try: page.add_init_js(script) ok = True except Exception as exc: if log_callback: log_callback(f"[Debug] stealth add_init_js failed: {exc}") try: page.run_js(script) ok = True except Exception as exc: if log_callback: log_callback(f"[Debug] stealth run_js failed: {exc}") if ok: try: page._grok_stealth_applied = True except Exception: pass if log_callback: log_callback(f"[*] stealth patches applied (mode={mode})") return ok def create_browser_options(headless=None): """Zip-style Chromium options (grok-register / ReinerBRO). Critical for CF: - always load turnstilePatch - no --disable-images - no browser_path unless config/env set - no AutomationControlled / heavy stealth flags on headed """ options = ChromiumOptions() options.auto_port() options.set_timeouts(base=1) # ReinerBRO headed flags for flag in ( "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", ): options.set_argument(flag) use_headless = should_use_headless(headless, log_hint=True) if use_headless: apply_headless_to_options(options, True) for flag in ( "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--mute-audio", ): options.set_argument(flag) print(" [browser] headless=true", flush=True) else: print(" [browser] headless=false (zip headed)", flush=True) # system default unless override browser_path = resolve_browser_path() if browser_path: try: options.set_browser_path(browser_path) print(f" [browser] path={browser_path}", flush=True) except Exception as exc: print(f" [browser] set path failed: {exc}", flush=True) else: print(" [browser] path=system-default (zip style)", flush=True) # Force UA from config (Chrome/138 etc.) try: ua = get_user_agent() if ua: options.set_user_agent(ua) print(f" [browser] ua={ua[:60]}...", flush=True) except Exception: pass if os.path.exists(EXTENSION_PATH): try: options.add_extension(EXTENSION_PATH) print(f" [ext] turnstilePatch loaded: {EXTENSION_PATH}", flush=True) except Exception as ext_exc: print(f" [ext] turnstilePatch load failed: {ext_exc}", flush=True) else: print(f" [ext] turnstilePatch missing: {EXTENSION_PATH}", flush=True) proxy = (config.get("proxy") or "").strip() if isinstance(config, dict) else "" if proxy: try: from urllib.parse import urlparse u = urlparse(proxy if "://" in proxy else f"http://{proxy}") host = u.hostname or "" if host: port = u.port or (443 if (u.scheme or "http") == "https" else 80) scheme = u.scheme or "http" options.set_argument(f"--proxy-server={scheme}://{host}:{port}") print(f" [proxy] browser --proxy-server={scheme}://{host}:{port}", flush=True) except Exception as e: print(f" [proxy] set browser proxy failed: {e}", flush=True) return options def _build_request_kwargs(**kwargs): request_kwargs = dict(kwargs) proxies = request_kwargs.pop("proxies", None) if proxies is None: proxies = get_proxies() if proxies: request_kwargs["proxies"] = proxies request_kwargs.setdefault("timeout", 15) return request_kwargs def http_get(url, **kwargs): try: return requests.get(url, **_build_request_kwargs(**kwargs)) except Exception as exc: err = str(exc) # 代理不可用时自动回退为直连,避免整个流程直接失败 if "127.0.0.1 port 7890" in err or "Could not connect to server" in err: retry_kwargs = dict(kwargs) retry_kwargs["proxies"] = {} return requests.get(url, **_build_request_kwargs(**retry_kwargs)) raise def http_post(url, **kwargs): try: return requests.post(url, **_build_request_kwargs(**kwargs)) except Exception as exc: err = str(exc) if "127.0.0.1 port 7890" in err or "Could not connect to server" in err: retry_kwargs = dict(kwargs) retry_kwargs["proxies"] = {} return requests.post(url, **_build_request_kwargs(**retry_kwargs)) raise def raise_if_cancelled(cancel_callback=None): if cancel_callback and cancel_callback(): raise RegistrationCancelled("用户停止注册") def sleep_with_cancel(seconds, cancel_callback=None): deadline = time.time() + max(seconds, 0) while True: raise_if_cancelled(cancel_callback) remaining = deadline - time.time() if remaining <= 0: return time.sleep(min(0.2, remaining)) def human_sleep(mean_seconds, cancel_callback=None): """高斯分布人类化延迟,sigma=mean*0.3,clamp [mean*0.5, mean*2.0]。 PERF_FLAGS sleep_scale / fast 可压缩批量注册等待。 """ scale = float(PERF_FLAGS.get("sleep_scale", 1.0) or 1.0) if PERF_FLAGS.get("fast"): scale = min(scale, 0.15) mean_seconds = max(0.0, float(mean_seconds) * scale) if mean_seconds <= 0.01: raise_if_cancelled(cancel_callback) return try: delay = random.gauss(mean_seconds, mean_seconds * 0.3) except Exception: delay = mean_seconds delay = max(mean_seconds * 0.5, min(mean_seconds * 2.0, delay)) sleep_with_cancel(delay, cancel_callback) def cf_sleep(seconds, cancel_callback=None, *, floor: float = 0.6): """Turnstile/Cloudflare 专用等待:fast 模式仍保留足够墙钟时间。 human_sleep 在 --fast 下会被压到 ~0.15x,导致 CF iframe 根本来不及加载/解题。 这里仅轻度缩放,并强制 floor,避免 token 恒为 0。 """ try: sec = float(seconds) except Exception: sec = 1.0 scale = float(PERF_FLAGS.get("sleep_scale", 1.0) or 1.0) if PERF_FLAGS.get("fast"): # 最多压到 0.55x,且不低于 floor scale = max(0.55, min(scale, 1.0)) delay = max(float(floor), sec * scale) # 轻微抖动,避免机械节奏 try: delay = max(float(floor), min(delay * 1.35, random.gauss(delay, delay * 0.12))) except Exception: pass sleep_with_cancel(delay, cancel_callback) def get_domains(api_key=None): headers = {} key = api_key or get_duckmail_api_key() if key: headers["Authorization"] = f"Bearer {key}" resp = http_get(f"{DUCKMAIL_API_BASE}/domains", headers=headers) resp.raise_for_status() return resp.json().get("hydra:member", []) def create_account(address, password, api_key=None, expires_in=0): headers = {"Content-Type": "application/json"} key = api_key or get_duckmail_api_key() if key: headers["Authorization"] = f"Bearer {key}" data = {"address": address, "password": password, "expiresIn": expires_in} resp = http_post(f"{DUCKMAIL_API_BASE}/accounts", json=data, headers=headers) resp.raise_for_status() return resp.json() def get_token(address, password): data = {"address": address, "password": password} resp = http_post(f"{DUCKMAIL_API_BASE}/token", json=data) resp.raise_for_status() return resp.json().get("token") def get_messages(token): headers = {"Authorization": f"Bearer {token}"} resp = http_get(f"{DUCKMAIL_API_BASE}/messages", headers=headers) resp.raise_for_status() return resp.json().get("hydra:member", []) def get_message_detail(token, message_id): headers = {"Authorization": f"Bearer {token}"} resp = http_get(f"{DUCKMAIL_API_BASE}/messages/{message_id}", headers=headers) resp.raise_for_status() return resp.json() def cloudflare_get_domains(api_base, api_key=None): headers = cloudflare_build_headers(content_type=False) if api_key and "Authorization" in headers: headers["Authorization"] = f"Bearer {api_key}" if api_key and "X-API-Key" in headers: headers["X-API-Key"] = api_key path = get_cloudflare_path("cloudflare_path_domains", "/domains") params = cloudflare_apply_auth_params() resp = http_get(f"{api_base}{path}", headers=headers, params=params) resp.raise_for_status() return _pick_list_payload(resp.json()) def cloudflare_create_account(api_base, address, password, api_key=None, expires_in=0): headers = cloudflare_build_headers(content_type=True) if api_key and "Authorization" in headers: headers["Authorization"] = f"Bearer {api_key}" if api_key and "X-API-Key" in headers: headers["X-API-Key"] = api_key payload = {"address": address, "password": password, "expiresIn": expires_in} path = get_cloudflare_path("cloudflare_path_accounts", "/accounts") params = cloudflare_apply_auth_params() resp = http_post(f"{api_base}{path}", json=payload, headers=headers, params=params) resp.raise_for_status() return resp.json() def cloudflare_get_token(api_base, address, password, api_key=None): headers = cloudflare_build_headers(content_type=True) if api_key and "Authorization" in headers: headers["Authorization"] = f"Bearer {api_key}" if api_key and "X-API-Key" in headers: headers["X-API-Key"] = api_key path = get_cloudflare_path("cloudflare_path_token", "/token") resp = http_post( f"{api_base}{path}", json={"address": address, "password": password}, headers=headers, params=cloudflare_apply_auth_params(), ) resp.raise_for_status() data = resp.json() if isinstance(data, dict): if data.get("token"): return data.get("token") if isinstance(data.get("data"), dict) and data["data"].get("token"): return data["data"].get("token") return None def cloudflare_get_messages(api_base, token): headers = {"Authorization": f"Bearer {token}"} path = get_cloudflare_path("cloudflare_path_messages", "/messages") params = {"limit": 20, "offset": 0} params = cloudflare_apply_auth_params(params) resp = http_get(f"{api_base}{path}", headers=headers, params=params) resp.raise_for_status() try: data = resp.json() except Exception: raise Exception(f"Cloudflare messages 返回非JSON: {resp.text[:300]}") return _pick_list_payload(data) def cloudflare_get_message_detail(api_base, token, message_id): headers = {"Authorization": f"Bearer {token}"} candidates = [ f"{api_base}/api/mail/{message_id}", f"{api_base}{get_cloudflare_path('cloudflare_path_messages', '/messages')}/{message_id}", ] last_err = None for url in candidates: try: resp = http_get( url, headers=headers, params=cloudflare_apply_auth_params(), ) resp.raise_for_status() data = resp.json() if isinstance(data, dict) and isinstance(data.get("data"), dict): return data["data"] return data except Exception as exc: last_err = exc continue raise Exception(f"Cloudflare 获取邮件详情失败: {last_err}") YYDS_API_BASE = "https://maliapi.215.im/v1" def get_yyds_api_key(): return config.get("yyds_api_key", "") def get_yyds_jwt(): return config.get("yyds_jwt", "") def yyds_get_domains(api_key=None, jwt=None): key = api_key or get_yyds_api_key() token = jwt or get_yyds_jwt() headers = {} if token: headers["Authorization"] = f"Bearer {token}" elif key: headers["X-API-Key"] = key resp = http_get(f"{YYDS_API_BASE}/domains", headers=headers) resp.raise_for_status() data = resp.json() return data.get("data", []) if data.get("success") else [] def yyds_create_account(address=None, domain=None, api_key=None, jwt=None): key = api_key or get_yyds_api_key() token = jwt or get_yyds_jwt() headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" elif key: headers["X-API-Key"] = key payload = {} if address: payload["address"] = address if domain: payload["domain"] = domain elif key or token: payload["autoDomainStrategy"] = "prefer_owned" resp = http_post(f"{YYDS_API_BASE}/accounts", json=payload, headers=headers) resp.raise_for_status() data = resp.json() if data.get("success"): return data.get("data", {}) raise Exception(f"YYDS 创建邮箱失败: {data}") def yyds_get_token(address, api_key=None, jwt=None): key = api_key or get_yyds_api_key() token = jwt or get_yyds_jwt() headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" elif key: headers["X-API-Key"] = key resp = http_post( f"{YYDS_API_BASE}/token", json={"address": address}, headers=headers ) resp.raise_for_status() data = resp.json() if data.get("success"): return data.get("data", {}).get("token") raise Exception(f"YYDS 获取token失败: {data}") def yyds_get_messages(address, token=None, api_key=None, jwt=None): key = api_key or get_yyds_api_key() temp_token = token or jwt or get_yyds_jwt() headers = {} if temp_token: headers["Authorization"] = f"Bearer {temp_token}" elif key: headers["X-API-Key"] = key resp = http_get( f"{YYDS_API_BASE}/messages", params={"address": address}, headers=headers, ) resp.raise_for_status() data = resp.json() if data.get("success"): return data.get("data", {}).get("messages", []) return [] def yyds_get_message_detail(message_id, token=None, api_key=None, jwt=None): key = api_key or get_yyds_api_key() temp_token = token or jwt or get_yyds_jwt() headers = {} if temp_token: headers["Authorization"] = f"Bearer {temp_token}" elif key: headers["X-API-Key"] = key resp = http_get(f"{YYDS_API_BASE}/messages/{message_id}", headers=headers) resp.raise_for_status() data = resp.json() if data.get("success"): return data.get("data", {}) raise Exception(f"YYDS 获取邮件详情失败: {data}") def yyds_generate_username(length=10): chars = string.ascii_lowercase + string.digits return "".join(secrets.choice(chars) for _ in range(length)) def yyds_pick_domain(api_key=None, jwt=None): domains = yyds_get_domains(api_key=api_key, jwt=jwt) if not domains: raise Exception("YYDS 没有返回任何可用域名") private = [d for d in domains if d.get("isVerified") and not d.get("isPublic")] if private: return private[0]["domain"] public = [d for d in domains if d.get("isVerified") and d.get("isPublic")] if public: return public[0]["domain"] verified = [d for d in domains if d.get("isVerified")] if verified: return verified[0]["domain"] raise Exception("YYDS 无已验证域名可用") def yyds_get_email_and_token(api_key=None, jwt=None): key = api_key or get_yyds_api_key() token = jwt or get_yyds_jwt() if not token and not key: raise Exception("YYDS API Key 或 JWT 未配置") domain = yyds_pick_domain(api_key=key, jwt=token) username = yyds_generate_username(10) result = yyds_create_account( address=username, domain=domain, api_key=key, jwt=token ) address = result.get("address") or f"{username}@{domain}" temp_token = result.get("token") if not temp_token: temp_token = yyds_get_token(address, api_key=key, jwt=token) if not temp_token: raise Exception("获取 YYDS token 失败") print(f"[*] 已创建 YYDS 邮箱: {address}") return address, temp_token def yyds_get_oai_code( token, address, timeout=180, poll_interval=3, log_callback=None, jwt=None, cancel_callback=None, ): deadline = time.time() + timeout seen_ids = set() while time.time() < deadline: raise_if_cancelled(cancel_callback) try: messages = yyds_get_messages(address, token=token, jwt=jwt) except Exception as exc: if log_callback: log_callback(f"[Debug] YYDS 拉取邮件列表失败: {exc}") sleep_with_cancel(poll_interval, cancel_callback) continue for msg in messages: msg_id = msg.get("id") if not msg_id or msg_id in seen_ids: continue seen_ids.add(msg_id) to_addrs = [t.get("address", "").lower() for t in (msg.get("to") or [])] if address.lower() not in to_addrs: continue try: detail = yyds_get_message_detail(msg_id, token=token, jwt=jwt) except Exception as exc: if log_callback: log_callback(f"[Debug] YYDS 获取邮件详情失败: {exc}") continue parts = [] text_body = detail.get("text") or "" if text_body: parts.append(text_body) html_list = detail.get("html") or [] for h in html_list: parts.append(re.sub(r"<[^>]+>", " ", h)) combined = "\n".join(parts) subject = detail.get("subject", "") if log_callback: log_callback(f"[Debug] YYDS 收到邮件: {subject}") code = extract_verification_code(combined, subject) if code: if log_callback: log_callback(f"[*] YYDS 从邮件中提取到验证码: {code}") return code sleep_with_cancel(poll_interval, cancel_callback) raise Exception(f"YYDS 在 {timeout}s 内未收到验证码邮件") def generate_username(length=10): chars = string.ascii_lowercase + string.digits return "".join(secrets.choice(chars) for _ in range(length)) def pick_domain(api_key=None): domains = get_domains(api_key=api_key) if not domains: raise Exception("DuckMail 没有返回任何可用域名") private = [d for d in domains if d.get("ownerId")] verified_private = [d for d in private if d.get("isVerified")] if verified_private: return verified_private[0]["domain"] public = [d for d in domains if d.get("isVerified")] if public: return public[0]["domain"] raise Exception("DuckMail 无已验证域名可用") # ──────────────────────── CloudMail (maillab/cloud-mail) ──────────────────────── # API 前缀: /api/(所有接口均挂载在 /api/ 下) # 认证格式: Authorization: (不带 Bearer 前缀) # 公开 token 通过 /api/public/genToken 获取(需管理员账号) def get_cloudmail_url(): return str(os.environ.get("CLOUDMAIL_URL") or config.get("cloudmail_url", "") or "").rstrip("/") def get_cloudmail_password(): return os.environ.get("CLOUDMAIL_PASSWORD") or config.get("cloudmail_password", "") def get_cloudmail_admin_email(): return str(os.environ.get("CLOUDMAIL_ADMIN_EMAIL") or config.get("cloudmail_admin_email", "") or "").strip() def cloudmail_login(url, email, password): """POST /api/login -> JWT string""" resp = http_post( f"{url}/api/login", json={"email": email, "password": password}, headers={"Content-Type": "application/json"}, ) resp.raise_for_status() data = resp.json() if isinstance(data, dict) and data.get("code") == 200: token_data = data.get("data", {}) if isinstance(token_data, dict): jwt = token_data.get("token") if jwt: return jwt raise Exception(f"CloudMail 登录失败: {str(data)[:200]}") def cloudmail_register(url, email, password, turnstile_token=""): """POST /api/register -> 注册用户+账号""" payload = {"email": email, "password": password} if turnstile_token: payload["token"] = turnstile_token resp = http_post( f"{url}/api/register", json=payload, headers={"Content-Type": "application/json"}, ) resp.raise_for_status() data = resp.json() if isinstance(data, dict) and data.get("code") != 200: raise Exception(f"CloudMail 注册失败: {data.get('message', str(data))}") return data def cloudmail_gen_public_token(url, admin_email, admin_password): """POST /api/public/genToken -> 公开 API token (UUID)""" resp = http_post( f"{url}/api/public/genToken", json={"email": admin_email, "password": admin_password}, headers={"Content-Type": "application/json"}, proxies={}, ) resp.raise_for_status() data = resp.json() if isinstance(data, dict) and data.get("code") == 200: token_data = data.get("data", {}) if isinstance(token_data, dict): return token_data.get("token") raise Exception(f"CloudMail 获取公开 token 失败: {str(data)[:200]}") def cloudmail_public_email_list(url, public_token, to_email="", size=20): """POST /api/public/emailList -> 公开邮件查询(需公开 token,Authorization: )""" payload = {"size": size} if to_email: payload["toEmail"] = to_email resp = http_post( f"{url}/api/public/emailList", json=payload, headers={ "Content-Type": "application/json", "Authorization": public_token, }, proxies={}, ) resp.raise_for_status() data = resp.json() if isinstance(data, dict): if data.get("code") == 200: return data.get("data", []) raise Exception(f"CloudMail 邮件查询失败: {data.get('message', str(data))}") return [] def _cloudmail_get_shared_token(force_refresh=False): """获取或刷新共享的公开 token(线程安全单例)""" global _cloudmail_public_token with _cloudmail_public_token_lock: if _cloudmail_public_token and not force_refresh: return _cloudmail_public_token url = get_cloudmail_url() admin_email = get_cloudmail_admin_email() admin_password = get_cloudmail_password() if not url or not admin_email or not admin_password: raise Exception("CloudMail 配置不完整") token = cloudmail_gen_public_token(url, admin_email, admin_password) if not token: raise Exception("CloudMail 公开 token 为空") _cloudmail_public_token = token return token def cloudmail_get_oai_code( dev_token, email, timeout=300, poll_interval=None, log_callback=None, cancel_callback=None, resend_callback=None, ): # 使用配置的 mail_poll_interval,默认 0.3s if poll_interval is None: poll_interval = max(0.1, float(config.get("mail_poll_interval", 0.3) or 0.3)) url = get_cloudmail_url() if not url: raise Exception("CloudMail URL 未配置") # 获取共享公开 token(所有线程共用同一个,避免并发覆盖) try: public_token = _cloudmail_get_shared_token() except Exception as exc: raise Exception(f"CloudMail 获取公开 token 失败: {exc}") if log_callback: log_callback("[Debug] CloudMail 公开 token 获取成功") deadline = time.time() + timeout seen_attempts = {} next_resend_at = time.time() + 60 while time.time() < deadline: raise_if_cancelled(cancel_callback) if resend_callback and time.time() >= next_resend_at: try: resend_callback() if log_callback: log_callback("[*] 已触发重新发送验证码") except Exception as exc: if log_callback: log_callback(f"[Debug] 触发重发验证码失败: {exc}") next_resend_at = time.time() + 60 # 统一使用 poll_interval(0.3s 短轮询,无需前加速) current_interval = poll_interval # 用完整邮箱地址查询(公开 API 的 toEmail 需要完整地址) try: messages = cloudmail_public_email_list(url, public_token, to_email=email, size=20) except Exception as exc: err_msg = str(exc) if log_callback: log_callback(f"[Debug] CloudMail 邮件查询失败: {err_msg}") # token 失效时,刷新共享 token(加锁,多线程只刷新一次) if "token" in err_msg.lower() or "401" in err_msg: try: public_token = _cloudmail_get_shared_token(force_refresh=True) if log_callback: log_callback("[Debug] CloudMail 公开 token 已刷新") except Exception: pass sleep_with_cancel(current_interval, cancel_callback) continue if log_callback: log_callback(f"[Debug] CloudMail 本轮邮件数量: {len(messages)}") for msg in messages: msg_id = msg.get("emailId") or msg.get("id") or msg.get("messageId") if not msg_id: continue attempt = int(seen_attempts.get(msg_id, 0)) if attempt >= 5: continue seen_attempts[msg_id] = attempt + 1 # 提取邮件内容(公开接口返回 content 字段,为完整 HTML) parts = [] for field in ("content", "text", "textContent", "text_content", "body", "snippet", "intro"): value = msg.get(field) if isinstance(value, str) and value.strip(): parts.append(value) html_val = msg.get("html") or msg.get("htmlContent") or msg.get("html_content") if isinstance(html_val, str): parts.append(re.sub(r"<[^>]+>", " ", html_val)) elif isinstance(html_val, list): for h in html_val: if isinstance(h, str): parts.append(re.sub(r"<[^>]+>", " ", h)) subject = str(msg.get("subject", "") or "") combined = "\n".join(parts) if log_callback: log_callback(f"[Debug] CloudMail 收到邮件: {subject}") code = extract_verification_code(combined, subject) if code: if log_callback: log_callback(f"[*] CloudMail 从邮件中提取到验证码: {code}") return code elif log_callback: log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}") sleep_with_cancel(current_interval, cancel_callback) raise Exception(f"CloudMail 在 {timeout}s 内未收到验证码邮件") # ──────────────────────── Hotmail / Outlook OAuth2 IMAP ──────────────────────── # 导入格式兼容 grok-register:邮箱----密码----ClientID----Token # 其中 Token 为 Microsoft OAuth2 refresh_token,验证码通过 outlook.office365.com XOAUTH2 IMAP 拉取。 HOTMAIL_TOKEN_ENDPOINTS = [ # IMAP XOAUTH2 needs an Outlook resource token. Graph Mail.Read tokens can # refresh successfully but then fail at IMAP with "authenticated but not connected". ( "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", {"scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"}, ), ( "https://login.live.com/oauth20_token.srf", {"scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"}, ), # Fallbacks for existing refresh tokens that were issued with legacy/default scopes. ("https://login.live.com/oauth20_token.srf", {}), ( "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", { "scope": ( "offline_access https://graph.microsoft.com/Mail.Read " "https://graph.microsoft.com/User.Read" ) }, ), ] def _config_bool(value, default=False): if value is None: return default if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): v = value.strip().lower() if v in ("1", "true", "yes", "y", "on"): return True if v in ("0", "false", "no", "n", "off"): return False return default def _resolve_project_path(path_value, default_name="mail_credentials.txt"): raw = str(path_value or default_name).strip() if not raw: raw = default_name if os.path.isabs(raw): return raw return os.path.join(os.path.dirname(os.path.abspath(__file__)), raw) def get_hotmail_accounts_file(): return _resolve_project_path(config.get("hotmail_accounts_file", "mail_credentials.txt")) def _hotmail_release_alias(email): if not email: return with _hotmail_selection_lock: _hotmail_reserved_aliases.discard(email.strip().lower()) def _hotmail_split_credential_line(line): parts = line.rstrip("\n").split("----", 3) if len(parts) < 4: return None email_addr = parts[0].strip() password = parts[1].strip() client_id = parts[2].strip() refresh_token = parts[3].strip() if not email_addr or "@" not in email_addr or not client_id or not refresh_token: return None return { "email": email_addr, "password": password, "client_id": client_id, "refresh_token": refresh_token, } def _hotmail_load_accounts(force=False): global _hotmail_accounts_cache, _hotmail_accounts_mtime path = get_hotmail_accounts_file() if not os.path.exists(path): raise Exception(f"Hotmail/Outlook 账号文件不存在: {path}") mtime = os.path.getmtime(path) with _hotmail_accounts_lock: if ( not force and _hotmail_accounts_cache is not None and _hotmail_accounts_mtime == mtime ): return _hotmail_accounts_cache accounts = [] seen_emails = set() with open(path, "r", encoding="utf-8-sig") as f: for line_no, raw in enumerate(f, 1): line = raw.strip() if not line or line.startswith("#") or line.startswith("//"): continue item = _hotmail_split_credential_line(raw) if not item: print(f"[Hotmail] 跳过无效账号行 {line_no}: {line[:80]}") continue email_key = item["email"].strip().lower() if email_key in seen_emails: print(f"[Hotmail] 跳过重复主邮箱行 {line_no}: {item['email']}") continue seen_emails.add(email_key) item["line_no"] = line_no accounts.append(item) if not accounts: raise Exception(f"Hotmail/Outlook 账号文件无有效记录: {path}") _hotmail_accounts_cache = accounts _hotmail_accounts_mtime = mtime return _hotmail_accounts_cache def _hotmail_split_email_addr(email_addr): raw = str(email_addr or "").strip().lower() if "@" not in raw: return "", "" local, domain = raw.rsplit("@", 1) return local, domain def _hotmail_is_alias_of_main(email_addr, main_email): local, domain = _hotmail_split_email_addr(email_addr) main_local, main_domain = _hotmail_split_email_addr(main_email) if not local or not main_local or domain != main_domain: return False return local == main_local or local.startswith(main_local + "+") def _hotmail_iter_tracked_emails(): """Yield emails already persisted in success/error ledgers.""" for fpath in (_EMAILS_USED_FILE, _EMAILS_ERROR_FILE): if not os.path.exists(fpath): continue try: with open(fpath, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue email_addr = line.split("----", 1)[0].strip() if email_addr: yield email_addr except Exception: continue def _hotmail_count_consumed_for_main(main_email): """Count used/failed/reserved aliases belonging to one Hotmail main mailbox.""" consumed = set() for email_addr in _hotmail_iter_tracked_emails(): if _hotmail_is_alias_of_main(email_addr, main_email): consumed.add(email_addr.strip().lower()) for email_addr in _hotmail_reserved_aliases: if _hotmail_is_alias_of_main(email_addr, main_email): consumed.add(email_addr.strip().lower()) return len(consumed) def _hotmail_alias_available(alias_email): alias_key = alias_email.strip().lower() return alias_key and alias_key not in _hotmail_reserved_aliases and not is_email_used(alias_email) def _hotmail_random_suffix(main_local): try: configured_len = int(config.get("hotmail_alias_random_length", 8) or 8) except Exception: configured_len = 8 # Outlook local-part max is 64 chars; keep suffix valid even for long usernames. max_len = max(1, 64 - len(str(main_local or "")) - 1) length = max(1, min(configured_len, max_len)) alphabet = string.ascii_lowercase + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def _hotmail_make_alias(main_email, alias_index, *, randomize=False): if alias_index <= 0: return main_email local, domain = main_email.split("@", 1) if randomize: return f"{local}+{_hotmail_random_suffix(local)}@{domain}" return f"{local}+{alias_index}@{domain}" def hotmail_get_email_and_token(): accounts = _hotmail_load_accounts() try: max_aliases = int(config.get("hotmail_max_aliases_per_account", 5) or 5) except Exception: max_aliases = 5 max_aliases = max(1, max_aliases) alias_mode = str(config.get("hotmail_alias_mode", "random") or "random").strip().lower() random_mode = alias_mode in ("random", "rand", "随机") try: random_max_attempts = int(config.get("hotmail_alias_random_max_attempts", 200) or 200) except Exception: random_max_attempts = 200 random_max_attempts = max(10, random_max_attempts) with _hotmail_selection_lock: for acc in accounts: main_email = acc["email"].strip() if "@" not in main_email: continue if _hotmail_count_consumed_for_main(main_email) >= max_aliases: continue candidate = None # 原邮箱仍优先尝试一次;之后 random 模式使用随机 plus alias。 if _hotmail_alias_available(main_email): candidate = main_email elif random_mode: for _ in range(random_max_attempts): if _hotmail_count_consumed_for_main(main_email) >= max_aliases: break alias_email = _hotmail_make_alias(main_email, 1, randomize=True) if _hotmail_alias_available(alias_email): candidate = alias_email break else: for alias_index in range(1, max_aliases): alias_email = _hotmail_make_alias(main_email, alias_index) if _hotmail_alias_available(alias_email): candidate = alias_email break if not candidate: continue alias_key = candidate.lower() _hotmail_reserved_aliases.add(alias_key) token_key = "hotmail:" + secrets.token_urlsafe(18) _hotmail_token_map[token_key] = { "account": acc, "email": candidate, "created_at": time.time(), } return candidate, token_key raise Exception( "Hotmail/Outlook 可用别名已耗尽:请增加 hotmail_max_aliases_per_account、" "补充 mail_credentials.txt,或清理 emails_used.txt / emails_error.txt" ) def _hotmail_get_refresh_lock(email_addr): key = email_addr.strip().lower() with _hotmail_refresh_locks_lock: lock = _hotmail_refresh_locks.get(key) if lock is None: lock = threading.Lock() _hotmail_refresh_locks[key] = lock return lock def _hotmail_update_refresh_token_file(email_addr, new_refresh_token, log_callback=None): path = get_hotmail_accounts_file() if not new_refresh_token or not os.path.exists(path): return with _hotmail_accounts_lock: try: with open(path, "r", encoding="utf-8-sig") as f: lines = f.readlines() changed = False out_lines = [] for raw in lines: item = _hotmail_split_credential_line(raw) if item and item["email"].lower() == email_addr.lower(): newline = ( f"{item['email']}----{item['password']}----" f"{item['client_id']}----{new_refresh_token}\n" ) out_lines.append(newline) changed = True else: out_lines.append(raw) if changed: with open(path, "w", encoding="utf-8") as f: f.writelines(out_lines) global _hotmail_accounts_mtime _hotmail_accounts_mtime = os.path.getmtime(path) if log_callback: log_callback(f"[*] Hotmail refresh_token 已回写: {email_addr}") except Exception as exc: if log_callback: log_callback(f"[Debug] Hotmail refresh_token 回写失败: {exc}") def hotmail_refresh_access_token(account, log_callback=None): email_addr = account["email"] lock = _hotmail_get_refresh_lock(email_addr) with lock: refresh_token = account.get("refresh_token", "") last_error = None for url, extra in HOTMAIL_TOKEN_ENDPOINTS: try: data = { "client_id": account["client_id"], "refresh_token": refresh_token, "grant_type": "refresh_token", **extra, } resp = http_post(url, data=data, timeout=30) try: token_data = resp.json() except Exception: token_data = {} access_token = token_data.get("access_token") if access_token: new_refresh = token_data.get("refresh_token") or refresh_token if new_refresh and new_refresh != refresh_token: account["refresh_token"] = new_refresh _hotmail_update_refresh_token_file( email_addr, new_refresh, log_callback=log_callback ) if log_callback: log_callback(f"[*] Hotmail OAuth2 access_token 刷新成功: {email_addr}") return access_token last_error = token_data.get("error_description") or token_data.get("error") or resp.text[:200] except Exception as exc: last_error = exc continue raise Exception(f"Hotmail OAuth2 refresh 失败: {last_error}") def _hotmail_decode_header(value): if not value: return "" try: from email.header import decode_header, make_header return str(make_header(decode_header(value))) except Exception: return str(value) def _hotmail_message_body(msg): import html as html_lib import re as re_lib def decode_part(part): payload = part.get_payload(decode=True) if payload is None: return "" charset = part.get_content_charset() or "utf-8" return payload.decode(charset, errors="ignore") if msg.is_multipart(): text_body = "" html_body = "" for part in msg.walk(): content_type = part.get_content_type() if content_type == "text/plain" and not text_body: text_body = decode_part(part) elif content_type == "text/html" and not html_body: html_body = decode_part(part) if text_body: return text_body return re_lib.sub(r"\s+", " ", re_lib.sub(r"<[^>]+>", " ", html_lib.unescape(html_body))).strip() return decode_part(msg) def _hotmail_get_imap_hosts(): raw = config.get("hotmail_imap_hosts", "outlook.office365.com,imap-mail.outlook.com") if isinstance(raw, (list, tuple)): hosts = [str(x).strip() for x in raw if str(x).strip()] else: hosts = [x.strip() for x in re.split(r"[,,\\s]+", str(raw or "")) if x.strip()] out = [] for host in hosts or ["outlook.office365.com", "imap-mail.outlook.com"]: if host not in out: out.append(host) return out def _hotmail_imap_get_code(mailbox_email, target_email, access_token, log_callback=None, host=None): import email as email_lib import imaplib from datetime import timezone from email.utils import parsedate_to_datetime try: recent_seconds = int(config.get("hotmail_recent_seconds", 900) or 900) except Exception: recent_seconds = 900 try: last_n = int(config.get("hotmail_imap_last_n", 30) or 30) except Exception: last_n = 30 require_recipient = _config_bool( config.get("hotmail_require_recipient_match", True), default=True ) filter_after_ts = int((time.time() - max(60, recent_seconds)) * 1000) target_lower = (target_email or "").strip().lower() keywords = ["x.ai", "xai", "grok", "verification", "code", "confirm", "验证码", "确认"] host = host or "outlook.office365.com" if log_callback: log_callback(f"[Debug] Hotmail/Outlook IMAP 连接: host={host} user={mailbox_email}") imap = imaplib.IMAP4_SSL(host, 993, timeout=45) auth_string = f"user={mailbox_email}\x01auth=Bearer {access_token}\x01\x01" imap.authenticate("XOAUTH2", lambda _: auth_string.encode()) try: imap.select("INBOX") status, data = imap.search(None, "ALL") if status != "OK" or not data or not data[0]: return None msg_ids = data[0].split()[-max(1, last_n):] for mid in reversed(msg_ids): _, msg_data = imap.fetch(mid, "(RFC822)") if not msg_data or not msg_data[0] or not isinstance(msg_data[0][1], bytes): continue msg = email_lib.message_from_bytes(msg_data[0][1]) date_str = msg.get("Date") if date_str: try: dt = parsedate_to_datetime(date_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) if int(dt.timestamp() * 1000) < filter_after_ts: continue except Exception: pass subject = _hotmail_decode_header(msg.get("Subject", "")) sender = _hotmail_decode_header(msg.get("From", "")) recipient_blob = " ".join( _hotmail_decode_header(msg.get(h, "")) for h in ( "To", "Cc", "Delivered-To", "X-Original-To", "Original-Recipient", "Envelope-To", ) ).lower() recipient_matched = not target_lower or target_lower in recipient_blob if require_recipient and not recipient_matched: continue body = _hotmail_message_body(msg) combined = f"{subject}\n{sender}\n{recipient_blob}\n{body}" combined_lower = combined.lower() if not any(kw in combined_lower for kw in keywords): continue code = extract_verification_code(combined, subject) if code: if log_callback: log_callback(f"[*] Hotmail/Outlook 从邮件中提取到验证码: {code}") if _config_bool(config.get("hotmail_delete_after_code", True), default=True): try: imap.store(mid, "+FLAGS", r"(\Deleted)") try: imap.expunge() except Exception: pass if log_callback: log_callback(f"[*] Hotmail/Outlook 已删除验证码邮件 mid={mid!r}") except Exception as del_exc: if log_callback: log_callback(f"[Debug] Hotmail/Outlook 删除验证码邮件失败: {del_exc}") return code return None finally: try: imap.close() except Exception: pass try: imap.logout() except Exception: pass def hotmail_get_oai_code( dev_token, email, timeout=180, poll_interval=3, log_callback=None, cancel_callback=None, resend_callback=None, ): token_info = _hotmail_token_map.get(dev_token) if not token_info: raise Exception("Hotmail/Outlook dev_token 无效或已过期") account = token_info["account"] mailbox_email = account["email"] try: configured_interval = float(config.get("hotmail_poll_interval", 5) or 5) except Exception: configured_interval = 5.0 current_interval = max(1.0, configured_interval or float(poll_interval or 3)) deadline = time.time() + timeout access_token = None next_resend_at = time.time() + 60 while time.time() < deadline: raise_if_cancelled(cancel_callback) if resend_callback and time.time() >= next_resend_at: try: resend_callback() if log_callback: log_callback("[*] 已触发重新发送验证码") except Exception as exc: if log_callback: log_callback(f"[Debug] 触发重发验证码失败: {exc}") next_resend_at = time.time() + 60 try: if not access_token: access_token = hotmail_refresh_access_token(account, log_callback=log_callback) code = None host_errors = [] for imap_host in _hotmail_get_imap_hosts(): try: code = _hotmail_imap_get_code( mailbox_email, email, access_token, log_callback=log_callback, host=imap_host, ) # 成功连接但本轮未找到码,不必再换同邮箱另一个 host 重扫。 break except Exception as host_exc: host_errors.append(f"{imap_host}: {host_exc}") if log_callback: log_callback(f"[Debug] Hotmail/Outlook IMAP host 失败: {imap_host}: {host_exc}") continue if code is None and host_errors and len(host_errors) >= len(_hotmail_get_imap_hosts()): raise Exception("; ".join(host_errors)) if code: return code if log_callback: log_callback(f"[Debug] Hotmail/Outlook 本轮未找到验证码: {email}") except Exception as exc: # OAuth/IMAP 临时失败时下一轮重新 refresh access_token。 access_token = None if log_callback: log_callback(f"[Debug] Hotmail/Outlook 拉取验证码失败: {exc}") sleep_with_cancel(current_interval, cancel_callback) raise Exception(f"Hotmail/Outlook 在 {timeout}s 内未收到验证码邮件: {email}") # ──────────────────────── 公共邮箱工具 ──────────────────────── def get_email_provider(): return str(config.get("email_provider", "duckmail") or "duckmail").strip().lower() # ──────────────────────── Gmail + Cloudflare catch-all ──────────────────────── def _gmail_worker_enabled() -> bool: if not _config_bool(config.get("gmail_worker_enabled", False), default=False): return False url = str(config.get("gmail_worker_url", "") or os.getenv("GMAIL_WORKER_URL", "") or "").strip() key = str(config.get("gmail_worker_api_key", "") or os.getenv("GMAIL_WORKER_API_KEY", "") or "").strip() return bool(url and key) def _gmail_worker_base_url() -> str: return str(config.get("gmail_worker_url", "") or os.getenv("GMAIL_WORKER_URL", "") or "").strip().rstrip("/") def _gmail_worker_api_key() -> str: return str(config.get("gmail_worker_api_key", "") or os.getenv("GMAIL_WORKER_API_KEY", "") or "").strip() def gmail_worker_fetch_code( email: str, *, timeout: float = 25.0, poll_interval: float | None = None, log_callback=None, cancel_callback=None, consume: bool = True, ) -> str | None: """Poll Cloudflare Email Worker KV HTTP API for a code. Returns code string or None on timeout / not configured. Does NOT raise on 404; raises only on hard misconfiguration after retries optional. """ base = _gmail_worker_base_url() key = _gmail_worker_api_key() if not base or not key: return None if poll_interval is None: try: poll_interval = float(config.get("gmail_worker_poll_interval", 0.4) or 0.4) except Exception: poll_interval = 0.4 poll_interval = max(0.15, min(float(poll_interval), 3.0)) try: timeout = float(timeout) except Exception: timeout = 25.0 deadline = time.time() + max(1.0, timeout) target = (email or "").strip().lower() if not target: return None t0 = time.time() polls = 0 last_err = "" url = f"{base}/code" while time.time() < deadline: raise_if_cancelled(cancel_callback) polls += 1 try: # Prefer no proxy for workers.dev / own CF endpoint (local latency) resp = http_get( url, params={ "to": target, "key": key, "consume": "1" if consume else "0", }, headers={"x-api-key": key}, timeout=8, proxies={}, ) if resp.status_code == 200: data = resp.json() if hasattr(resp, "json") else {} if not isinstance(data, dict): data = {} code = str(data.get("code") or "").strip() if code: if log_callback: log_callback( f"[*] Gmail Worker 取到验证码: {code} " f"(elapsed={time.time() - t0:.1f}s polls={polls} source={data.get('source') or 'worker'})" ) return code elif resp.status_code == 404: last_err = "not found" elif resp.status_code in (401, 403): last_err = f"auth {resp.status_code}" if log_callback and polls <= 2: log_callback(f"[Debug] Gmail Worker 鉴权失败 HTTP {resp.status_code}(检查 gmail_worker_api_key)") # auth errors won't recover quickly if polls >= 3: return None else: last_err = f"HTTP {resp.status_code}" if log_callback and polls <= 3: log_callback(f"[Debug] Gmail Worker 响应 {resp.status_code}: {str(getattr(resp, 'text', '') or '')[:120]}") except Exception as exc: last_err = str(exc) if log_callback and polls <= 3: log_callback(f"[Debug] Gmail Worker 请求失败: {exc}") sleep_with_cancel(poll_interval, cancel_callback) if log_callback: log_callback( f"[*] Gmail Worker {timeout:.0f}s 内未取到验证码 ({last_err or 'timeout'})," f"{'将回退 IMAP' if _config_bool(config.get('gmail_imap_fallback', True), default=True) else '结束'}" ) return None def gmail_get_email_and_token(): raw = str(config.get("defaultDomains", "") or "") domains = [x.strip() for x in re.split(r"[,,\s]+", raw) if x.strip()] if not domains: raise Exception("Gmail 模式需要在 defaultDomains 中配置域名,如 zhangyunuo.net") domain = domains[0] username = generate_username(10) address = f"{username}@{domain}" return address, "gmail_catch_all" def gmail_get_oai_code( dev_token, email, timeout=300, poll_interval=None, log_callback=None, cancel_callback=None, resend_callback=None, ): """Fetch xAI code: Cloudflare Email Worker first (optional), then Gmail IMAP. Hybrid path (recommended): xAI → CF Email Routing → Worker(KV) → GET /code (seconds) fallback → Gmail IMAP (if Worker miss / disabled) Worker subjects still look like: "ABC-DEF xAI confirmation code". IMAP path fetches HEADER only first (subject often has the code). """ # ── 1) Email Worker (KV HTTP) — fast path ── # Lesson from batch10: hits are ~1.5s; misses used to burn 25s worker + ~155s IMAP # even when Catch-all only delivers to Worker (IMAP never sees the mail). worker_on = _gmail_worker_enabled() imap_fallback = _config_bool(config.get("gmail_imap_fallback", True), default=True) if worker_on: try: # Prefer short wait: real hits arrive in 1–3s; long waits only delay retry. worker_timeout = float(config.get("gmail_worker_timeout_sec", 12) or 12) except Exception: worker_timeout = 12.0 worker_timeout = max(4.0, min(worker_timeout, float(timeout))) if log_callback: log_callback( f"[*] Gmail 取码:优先 Email Worker(timeout={worker_timeout:.0f}s)" + (",未命中再短等 IMAP" if imap_fallback else ",未命中即换号") ) code = gmail_worker_fetch_code( email, timeout=worker_timeout, log_callback=log_callback, cancel_callback=cancel_callback, consume=True, ) if code: return code if not imap_fallback: raise Exception( f"Gmail Worker 在 {worker_timeout:.0f}s 内未收到验证码(gmail_imap_fallback=false): {email}" ) # Short IMAP fallback only — Catch-all→Worker often never reaches Gmail. try: imap_fb = float(config.get("gmail_imap_fallback_timeout_sec", 35) or 35) except Exception: imap_fb = 35.0 timeout = max(15.0, min(imap_fb, max(0.0, float(timeout) - worker_timeout))) if log_callback: log_callback( f"[*] Gmail Worker 未命中,短回退 IMAP(timeout={timeout:.0f}s;" f"若邮件只进 Worker 请设 GMAIL_FORWARD_TO 或 gmail_imap_fallback=false)" ) if poll_interval is None: # Gmail catch-all benefits from denser polling; default 1s unless config forces higher. poll_interval = float(config.get("mail_poll_interval", 1) or 1) poll_interval = max(0.3, min(poll_interval, 5.0)) imap_user = str(config.get("gmail_imap_user", "") or os.getenv("GMAIL_USER", "") or "").strip() imap_password = str(config.get("gmail_imap_password", "") or os.getenv("GMAIL_PASSWORD", "") or "").strip() if not imap_user or not imap_password: if worker_on: raise Exception( f"Gmail Worker 未取到验证码,且 IMAP 未配置(gmail_imap_user/password): {email}" ) raise Exception("Gmail IMAP 未配置:需要在 config.json 设置 gmail_imap_user/gmail_imap_password,或环境变量 GMAIL_USER/GMAIL_PASSWORD") imap_host = str(config.get("gmail_imap_host", "imap.gmail.com") or "imap.gmail.com").strip() imap_port = int(config.get("gmail_imap_port", 993) or 993) delete_after = _config_bool(config.get("gmail_delete_after_code", True), default=True) deadline = time.time() + timeout # Resend: default 45s for pure IMAP; after worker-miss fallback use sooner resend. try: resend_after = float(config.get("gmail_resend_after_sec", 45) or 45) except Exception: resend_after = 45.0 if worker_on: # fallback window is short; resend once around 12s into IMAP resend_after = min(resend_after, max(8.0, float(config.get("gmail_imap_fallback_resend_sec", 12) or 12))) next_resend_at = time.time() + resend_after imap = None target_lower = email.lower().strip() seen_ids: set[bytes] = set() poll_n = 0 t0 = time.time() def _close_imap(conn): if conn is None: return try: conn.close() except Exception: pass try: conn.logout() except Exception: pass def _ensure_imap(): nonlocal imap if imap is not None: try: status, _ = imap.noop() if status == "OK": return imap except Exception: _close_imap(imap) imap = None imap = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=45) imap.login(imap_user, imap_password) imap.select("INBOX") return imap def _candidate_ids(conn): """Prefer targeted IMAP SEARCH; fall back to recent ALL.""" queries = [ f'(FROM "noreply@x.ai" TO "{target_lower}")', f'(FROM "x.ai" TO "{target_lower}")', f'(TO "{target_lower}")', '(FROM "noreply@x.ai")', "UNSEEN", "ALL", ] for q in queries: try: status, data = conn.search(None, q) except Exception: continue if status != "OK" or not data or not data[0]: continue ids = data[0].split() if not ids: continue # Only scan a small recent window if q in ("ALL", "UNSEEN", '(FROM "noreply@x.ai")'): ids = ids[-40:] return ids, q return [], "" def _header_blob(msg) -> tuple[str, str, str]: subject = _hotmail_decode_header(msg.get("Subject", "")) sender = _hotmail_decode_header(msg.get("From", "")) recipient_blob = " ".join( _hotmail_decode_header(msg.get(h, "")) for h in ("To", "Cc", "Delivered-To", "X-Original-To", "Original-Recipient", "Envelope-To") ) return subject, sender, recipient_blob def _delete_message(conn, mid, code: str) -> None: """Best-effort delete of used verification mail; never blocks returning the code.""" if not delete_after or conn is None or mid is None: return try: conn.store(mid, "+FLAGS", r"(\Deleted)") try: conn.expunge() except Exception: pass if log_callback: log_callback(f"[*] Gmail IMAP 已删除验证码邮件 mid={mid!r} code={code}") except Exception as exc: if log_callback: log_callback(f"[Debug] Gmail IMAP 删除验证码邮件失败: {exc}") try: while time.time() < deadline: raise_if_cancelled(cancel_callback) if resend_callback and time.time() >= next_resend_at: try: resend_callback() if log_callback: log_callback("[*] 已触发重新发送验证码") except Exception: pass next_resend_at = time.time() + 60 try: conn = _ensure_imap() msg_ids, qused = _candidate_ids(conn) poll_n += 1 if not msg_ids: sleep_with_cancel(poll_interval, cancel_callback) continue filter_ts = int((time.time() - 900) * 1000) for mid in reversed(msg_ids): if mid in seen_ids: continue try: # HEADER only — subject already contains ABC-DEF code _, msg_data = conn.fetch(mid, "(BODY.PEEK[HEADER])") except Exception: try: _, msg_data = conn.fetch(mid, "(RFC822.HEADER)") except Exception: continue if not msg_data or not msg_data[0] or not isinstance(msg_data[0][1], bytes): continue # Mark seen after successful header fetch to avoid reparse cost seen_ids.add(mid) msg = email_lib.message_from_bytes(msg_data[0][1]) date_str = msg.get("Date") if date_str: try: dt = parsedate_to_datetime(date_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) if int(dt.timestamp() * 1000) < filter_ts: continue except Exception: pass subject, sender, recipient_blob = _header_blob(msg) recipient_l = recipient_blob.lower() if target_lower not in recipient_l: # Targeted TO search already matched; still require recipient when possible if "TO" not in qused.upper(): continue combined = f"{subject}\n{sender}\n{recipient_blob}".lower() if not any(kw in combined for kw in ["x.ai", "xai", "grok", "verification", "code", "confirm", "确认", "验证码"]): continue code = extract_verification_code(combined, subject) if code: if log_callback: elapsed = time.time() - t0 log_callback( f"[*] Gmail IMAP 从邮件中提取到验证码: {code} " f"(elapsed={elapsed:.1f}s polls={poll_n} via={qused or 'ALL'})" ) _delete_message(conn, mid, code) return code # Rare: code only in body — fetch full message once try: _, full = conn.fetch(mid, "(BODY.PEEK[])") except Exception: try: _, full = conn.fetch(mid, "(RFC822)") except Exception: full = None if full and full[0] and isinstance(full[0][1], bytes): full_msg = email_lib.message_from_bytes(full[0][1]) body = _hotmail_message_body(full_msg) code = extract_verification_code(f"{subject}\n{body}", subject) if code: if log_callback: elapsed = time.time() - t0 log_callback( f"[*] Gmail IMAP 从邮件正文提取到验证码: {code} " f"(elapsed={elapsed:.1f}s polls={poll_n})" ) _delete_message(conn, mid, code) return code except Exception as exc: _close_imap(imap) imap = None if log_callback: log_callback(f"[Debug] Gmail IMAP 轮询失败: {exc}") sleep_with_cancel(poll_interval, cancel_callback) raise Exception(f"Gmail IMAP 在 {timeout}s 内未收到验证码邮件: {email}") finally: _close_imap(imap) def get_email_and_token(api_key=None): provider = get_email_provider() if provider in ("hotmail", "outlook", "outlookmail", "microsoft"): return hotmail_get_email_and_token() if provider == "yyds": return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt()) if provider == "cloudmail": # CloudMail catch-all 模式:直接生成随机邮箱,无需注册 # Cloudflare Email Routing 会自动将所有该域名的邮件路由到 Worker # 支持英文逗号、中文逗号、空格分隔 raw = str(config.get("defaultDomains", "") or "") domains = [x.strip() for x in re.split(r"[,,\s]+", raw) if x.strip()] if not domains: raise Exception("CloudMail 需要在 defaultDomains 中配置可用域名") global _cf_domain_index domain = domains[_cf_domain_index % len(domains)] _cf_domain_index += 1 username = generate_username(10) address = f"{username}@{domain}" # 返回占位 token(实际不用于邮件查询,邮件查询走公开 API) return address, "cloudmail_catch_all" if provider == "gmail": return gmail_get_email_and_token() if provider == "cloudflare": api_base = get_cloudflare_api_base() if not api_base: raise Exception("Cloudflare API Base 未配置") try: # cloudflare_temp_email 专用模式 return cloudflare_create_temp_address(api_base) except Exception as primary_exc: # 兜底回退到 Mail.tm 风格 key = api_key or get_cloudflare_api_key() domains = cloudflare_get_domains(api_base, api_key=key) if not domains: raise Exception(f"Cloudflare 创建邮箱失败: {primary_exc}") verified = [d for d in domains if d.get("isVerified")] target = verified[0] if verified else domains[0] domain = target.get("domain") if not domain: raise Exception("Cloudflare 域名数据格式错误,缺少 domain 字段") username = generate_username(10) address = f"{username}@{domain}" password = secrets.token_urlsafe(12) cloudflare_create_account( api_base, address, password, api_key=key, expires_in=0 ) token = cloudflare_get_token(api_base, address, password, api_key=key) if not token: raise Exception("获取 Cloudflare 邮箱 token 失败") return address, token key = api_key or get_duckmail_api_key() domain = pick_domain(api_key=key) username = generate_username(10) address = f"{username}@{domain}" password = secrets.token_urlsafe(12) create_account(address, password, api_key=key, expires_in=0) token = get_token(address, password) if not token: raise Exception("获取 DuckMail token 失败") return address, token def get_oai_code( dev_token, email, timeout=180, poll_interval=3, log_callback=None, cancel_callback=None, resend_callback=None, ): provider = get_email_provider() if provider in ("hotmail", "outlook", "outlookmail", "microsoft"): return hotmail_get_oai_code( dev_token, email, timeout=timeout, poll_interval=poll_interval, log_callback=log_callback, cancel_callback=cancel_callback, resend_callback=resend_callback, ) if provider == "yyds": return yyds_get_oai_code( dev_token, email, timeout=timeout, poll_interval=poll_interval, log_callback=log_callback, jwt=get_yyds_jwt(), cancel_callback=cancel_callback, ) if provider == "cloudmail": return cloudmail_get_oai_code( dev_token, email, timeout=timeout, poll_interval=poll_interval, log_callback=log_callback, cancel_callback=cancel_callback, resend_callback=resend_callback, ) if provider == "gmail": return gmail_get_oai_code( dev_token, email, timeout=timeout, poll_interval=poll_interval, log_callback=log_callback, cancel_callback=cancel_callback, resend_callback=resend_callback, ) if provider == "cloudflare": return cloudflare_get_oai_code( dev_token, email, timeout=timeout, poll_interval=poll_interval, log_callback=log_callback, cancel_callback=cancel_callback, resend_callback=resend_callback, ) return duckmail_get_oai_code( dev_token, email, timeout=timeout, poll_interval=poll_interval, log_callback=log_callback, cancel_callback=cancel_callback, ) def extract_verification_code(text, subject=""): if subject: match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE) if match: return match.group(1) match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE) if match: return match.group(1) patterns = [ r"verification\s+code[:\s]+(\d{4,8})", r"your\s+code[:\s]+(\d{4,8})", r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})", ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: return match.group(1) return None def duckmail_get_oai_code( dev_token, email, timeout=180, poll_interval=3, log_callback=None, cancel_callback=None, ): deadline = time.time() + timeout seen_ids = set() while time.time() < deadline: raise_if_cancelled(cancel_callback) try: messages = get_messages(dev_token) except Exception as exc: if log_callback: log_callback(f"[Debug] 拉取邮件列表失败: {exc}") sleep_with_cancel(poll_interval, cancel_callback) continue for msg in messages: msg_id = msg.get("id") or msg.get("msgid") if not msg_id or msg_id in seen_ids: continue seen_ids.add(msg_id) recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])] if email.lower() not in recipients: continue try: detail = get_message_detail(dev_token, msg_id) except Exception as exc: if log_callback: log_callback(f"[Debug] 获取邮件详情失败: {exc}") continue parts = [] text_body = detail.get("text") or "" if text_body: parts.append(text_body) html_list = detail.get("html") or [] for h in html_list: parts.append(re.sub(r"<[^>]+>", " ", h)) combined = "\n".join(parts) subject = detail.get("subject", "") if log_callback: log_callback(f"[Debug] 收到邮件: {subject}") code = extract_verification_code(combined, subject) if code: if log_callback: log_callback(f"[*] 从邮件中提取到验证码: {code}") return code sleep_with_cancel(poll_interval, cancel_callback) raise Exception(f"在 {timeout}s 内未收到验证码邮件") def cloudflare_get_oai_code( dev_token, email, timeout=180, poll_interval=3, log_callback=None, cancel_callback=None, resend_callback=None, ): api_base = get_cloudflare_api_base() if not api_base: raise Exception("Cloudflare API Base 未配置") deadline = time.time() + timeout # 同一封邮件正文可能延迟可读,允许多次重试解析,避免偶发漏码 seen_attempts = {} next_resend_at = time.time() + 35 while time.time() < deadline: raise_if_cancelled(cancel_callback) if resend_callback and time.time() >= next_resend_at: try: resend_callback() if log_callback: log_callback("[*] 已触发重新发送验证码") except Exception as exc: if log_callback: log_callback(f"[Debug] 触发重发验证码失败: {exc}") next_resend_at = time.time() + 35 try: messages = cloudflare_get_messages(api_base, dev_token) except Exception as exc: if log_callback: log_callback(f"[Debug] Cloudflare 拉取邮件列表失败: {exc}") sleep_with_cancel(poll_interval, cancel_callback) continue if log_callback: log_callback(f"[Debug] Cloudflare 本轮邮件数量: {len(messages)}") for msg in messages: msg_id = msg.get("id") or msg.get("msgid") if not msg_id: continue attempt = int(seen_attempts.get(msg_id, 0)) if attempt >= 5: continue seen_attempts[msg_id] = attempt + 1 recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])] msg_addr = str(msg.get("address", "")).lower() # 优先匹配目标邮箱;若结构不一致也允许继续解析,避免接口字段漂移导致漏码 address_matched = True if recipients: address_matched = email.lower() in recipients elif msg_addr: address_matched = msg_addr == email.lower() if not address_matched and log_callback: log_callback(f"[Debug] 跳过疑似非目标邮件 id={msg_id} address={msg_addr} to={recipients}") continue parts = [] # 先直接从列表项取内容,避免 detail 接口差异导致漏码 for field in ("text", "raw", "content", "intro", "body", "snippet"): value = msg.get(field) if isinstance(value, str) and value.strip(): parts.append(value) html_list = msg.get("html") or [] if isinstance(html_list, str): html_list = [html_list] for h in html_list: parts.append(re.sub(r"<[^>]+>", " ", h)) subject = str(msg.get("subject", "") or "") combined = "\n".join(parts) # 再尝试 detail 接口补全内容 try: detail = cloudflare_get_message_detail(api_base, dev_token, msg_id) for field in ("text", "raw", "content", "intro", "body", "snippet"): value = detail.get(field) if isinstance(value, str) and value.strip(): combined += "\n" + value html_list2 = detail.get("html") or [] if isinstance(html_list2, str): html_list2 = [html_list2] for h in html_list2: combined += "\n" + re.sub(r"<[^>]+>", " ", h) if not subject: subject = str(detail.get("subject", "") or "") except Exception as exc: if log_callback: log_callback(f"[Debug] Cloudflare detail接口失败,改用列表内容解析: {exc}") if log_callback: log_callback(f"[Debug] Cloudflare 收到邮件: {subject}") code = extract_verification_code(combined, subject) if code: if log_callback: log_callback(f"[*] Cloudflare 从邮件中提取到验证码: {code}") return code elif log_callback: log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}") sleep_with_cancel(poll_interval, cancel_callback) raise Exception(f"Cloudflare 在 {timeout}s 内未收到验证码邮件") def generate_random_birthdate(): import datetime as dt today = dt.date.today() age = random.randint(20, 40) birth_year = today.year - age birth_month = random.randint(1, 12) birth_day = random.randint(1, 28) return f"{birth_year}-{birth_month:02d}-{birth_day:02d}T16:00:00.000Z" def set_birth_date(session, log_callback=None): url = "https://grok.com/rest/auth/set-birth-date" new_headers = { "content-type": "application/json", "origin": "https://grok.com", "referer": "https://grok.com/", } payload = {"birthDate": generate_random_birthdate()} try: res = session.post(url, json=payload, headers=new_headers, timeout=15) if log_callback: log_callback( f"[Debug] set_birth_date status: {res.status_code}, body: {res.text[:200]}" ) return res.status_code == 200 except Exception as e: if log_callback: log_callback(f"[set_birth_date] 寮傚父: {e}") return False def set_tos_accepted(session, log_callback=None): url = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion" payload = struct.pack("B", (2 << 3) | 0) + struct.pack("B", 1) data = b"\x00" + struct.pack(">I", len(payload)) + payload new_headers = { "content-type": "application/grpc-web+proto", "x-grpc-web": "1", "x-user-agent": "connect-es/2.1.1", "origin": "https://accounts.x.ai", "referer": "https://accounts.x.ai/accept-tos", } try: res = session.post(url, data=data, headers=new_headers, timeout=15) if log_callback: log_callback(f"[Debug] set_tos_accepted status: {res.status_code}") return res.status_code == 200 except Exception as e: if log_callback: log_callback(f"[set_tos_accepted] 寮傚父: {e}") return False def encode_grpc_nsfw_settings(): field1_content = bytes([0x10, 0x01]) field1 = bytes([0x0A, len(field1_content)]) + field1_content nsfw_string = b"always_show_nsfw_content" field2_inner = bytes([0x0A, len(nsfw_string)]) + nsfw_string field2 = bytes([0x12, len(field2_inner)]) + field2_inner payload = field1 + field2 return b"\x00" + struct.pack(">I", len(payload)) + payload def update_nsfw_settings(session, log_callback=None): url = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls" data = encode_grpc_nsfw_settings() new_headers = { "content-type": "application/grpc-web+proto", "x-grpc-web": "1", "origin": "https://grok.com", "referer": "https://grok.com/", } try: res = session.post(url, data=data, headers=new_headers, timeout=15) if log_callback: log_callback(f"[Debug] update_nsfw status: {res.status_code}") return res.status_code == 200 except Exception as e: if log_callback: log_callback(f"[update_nsfw] 寮傚父: {e}") return False def enable_nsfw_for_token(token, cf_clearance="", log_callback=None): proxies = get_proxies() user_agent = get_user_agent() try: with requests.Session(impersonate="chrome120", proxies=proxies) as session: session.headers.update( { "user-agent": user_agent, "cookie": f"sso={token}; sso-rw={token}; cf_clearance={cf_clearance}", } ) if not set_tos_accepted(session, log_callback): return False, "set_tos_accepted 澶辫触!" if not set_birth_date(session, log_callback): return False, "set_birth_date 澶辫触!" if not update_nsfw_settings(session, log_callback): return False, "update_nsfw_settings 澶辫触!" return True, "成功开启NSFW" except Exception as e: return False, f"寮傚父: {str(e)}" SIGNUP_URL = "https://accounts.x.ai/sign-up?redirect=grok-com" _thread_ctx = threading.local() from tab_pool import TabPool def _get_browser(): return TabPool.get_browser() def _set_browser(value): pass # TabPool 管理 browser,外部 setter 为 no-op def _get_page(): if TabPool.get_browser() is None: return None return TabPool.get_tab() def _set_page(value): pass # TabPool 管理 tab,外部 setter 为 no-op def start_browser(log_callback=None): last_exc = None for attempt in range(1, 5): try: TabPool.init(create_browser_options, log_callback=log_callback) page = TabPool.get_tab() apply_stealth_patches(page, log_callback=log_callback) if log_callback and attempt > 1: log_callback(f"[*] 浏览器第 {attempt} 次启动成功") return TabPool.get_browser(), page except Exception as exc: last_exc = exc if log_callback: log_callback(f"[Debug] 浏览器启动失败(第{attempt}/4次): {exc}") # 每线程独立浏览器,shutdown 只影响当前线程 try: TabPool.release_tab() except Exception: pass human_sleep(min(1.5 * attempt, 4)) raise Exception(f"浏览器启动失败,已重试4次: {last_exc}") def stop_browser(): """Quit current-thread Chromium (full process exit + del_data).""" TabPool.release_tab() def prepare_browser_for_next_account(log_callback=None, force_recycle: bool = False): """Between accounts: clear session (reuse) or full recycle. Returns (browser, page). """ reuse = bool(PERF_FLAGS.get("browser_reuse", True)) and not force_recycle every = int(PERF_FLAGS.get("browser_recycle_every", 25) or 25) served = TabPool.served_count() if reuse and TabPool.get_browser() is not None and (every <= 0 or served < every): if TabPool.clear_session(log_callback=log_callback): TabPool.mark_served() page = _get_page() # Re-apply stealth after session wipe / about:blank apply_stealth_patches(page, log_callback=log_callback) return TabPool.get_browser(), page # full recycle if log_callback: log_callback(f"[*] 浏览器完整回收(reuse={reuse}, served={served}, every={every})") TabPool.release_tab() return start_browser(log_callback=log_callback) def shutdown_browser(): """Quit all tracked Chromium instances.""" TabPool.shutdown() def restart_browser(log_callback=None): TabPool.release_tab() return start_browser(log_callback=log_callback) def refresh_active_page(): if TabPool.get_browser() is None: restart_browser() try: browser = TabPool.get_browser() tabs = browser.tab_ids if tabs: page = browser.get_tab(tabs[-1]) else: page = browser.new_tab() page.refresh() TabPool.sync_tab() except Exception: restart_browser() return _get_page() def click_email_signup_button(timeout=10, log_callback=None, cancel_callback=None): page = _get_page() deadline = time.time() + timeout while time.time() < deadline: raise_if_cancelled(cancel_callback) if log_callback: log_callback("[Debug] 尝试查找“使用邮箱注册”按钮...") clicked = page.run_js(r""" const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]')); const target = candidates.find((node) => { const text = (node.innerText || node.textContent || '').replace(/\s+/g, ''); const lower = text.toLowerCase(); return ( text.includes('使用邮箱注册') || lower.includes('signupwithemail') || lower.includes('continuewithemail') || lower.includes('email') ); }); if (!target) { return false; } target.click(); return true; """) if clicked: if log_callback: log_callback("[*] 已点击「使用邮箱注册」按钮") human_sleep(2, cancel_callback) return True if log_callback: current_url = page.url if page else "none" log_callback(f"[Debug] 当前URL: {current_url}") human_sleep(1, cancel_callback) if log_callback: page_html = page.html[:500] if page else "no page" log_callback(f"[Debug] 页面内容片段: {page_html}") raise Exception("未找到「使用邮箱注册」按钮") def dismiss_cookie_banner(page=None, log_callback=None) -> bool: """Dismiss xAI/OneTrust cookie consent that blocks Sign up / Turnstile. Historical success path always did a real Accept All Cookies click. force-hide alone often leaves preference-center DOM and CF stays token=0. Prefer DrissionPage native element clicks; JS/force-hide only as fallback. IMPORTANT: once dismissed on a page, do NOT keep re-clicking OneTrust buttons during Turnstile Verifying — that restarts CF and leaves token=0 forever. """ page = page if page is not None else _get_page() if page is None: return False # Sticky per-page: avoid click storms while Turnstile is Verifying if getattr(page, "_grok_cookie_dismissed", False): # Still force-hide if preference center somehow re-opened try: still = page.run_js( r""" try { const ban = document.querySelector('#onetrust-banner-sdk, #onetrust-pc-sdk'); if (!ban) return false; const s = getComputedStyle(ban); const r = ban.getBoundingClientRect(); return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 40 && r.height > 40; } catch(e) { return false; } """ ) if not still: return False except Exception: return False # Ordered: accept/allow first (historical winner), then reject/confirm/close. native_texts = [ "Accept All Cookies", "Accept All", "Allow All", "Allow all", "接受所有 Cookie", "接受所有", "接受全部", "全部接受", "Reject All", "Reject all", "全部拒绝", "拒绝全部", "Confirm My Choices", "Confirm my choices", "确认我的选择", ] # Prefer real Accept/Allow All only. Do NOT click save-preference first — # that only saves partial choices and CF Verifying often never completes. css_ids = [ "#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", "#onetrust-reject-all-handler", ".ot-pc-refuse-all-handler", "#close-pc-btn-handler", ".onetrust-close-btn-handler", # save-preference last resort only ".save-preference-btn-handler", ] def _cookie_still_present() -> bool: try: return bool( page.run_js( r""" function vis(n){ if(!n) return false; try{ const s=getComputedStyle(n); if(s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0) return false; const r=n.getBoundingClientRect(); return r.width>0 && r.height>0; }catch(e){return false;} } if (['#onetrust-banner-sdk','#onetrust-consent-sdk','#onetrust-pc-sdk','.onetrust-pc-dark-filter'] .some(sel => vis(document.querySelector(sel)))) return true; return Array.from(document.querySelectorAll('button,a,[role="button"]')).some(n=>{ if(!vis(n)) return false; const t=((n.innerText||n.textContent||'')+'').toLowerCase(); return /accept all cookies|reject all|cookie settings|allow all|confirm my choices|接受所有|全部拒绝/.test(t); }); """ ) ) except Exception: return False def _native_click_once() -> str: # 1) OneTrust ids via CSS for sel in css_ids: try: el = page.ele(f"css:{sel}", timeout=0.25) except Exception: el = None if el is None: continue try: el.click() return f"clicked:{sel}" except Exception: try: el.click(by_js=True) return f"clicked-js:{sel}" except Exception: pass # 2) Enumerate ALL buttons/links in Python (more reliable than text: locator) prefer_keys = [ "accept all cookies", "accept all", "allow all", "allow all cookies", "接受所有 cookie", "接受所有", "接受全部", "全部接受", "允许全部", "reject all", "reject all cookies", "全部拒绝", "拒绝全部", # confirm/save last — partial consent often leaves CF Verifying forever "confirm my choices", "确认我的选择", "save preferences", "save my preferences", ] try: candidates = page.eles("tag:button", timeout=0.4) or [] except Exception: candidates = [] try: more = page.eles("css:a,[role='button']", timeout=0.2) or [] candidates = list(candidates) + list(more) except Exception: pass best = None best_label = "" best_rank = 10**9 for el in candidates: try: label = ( (getattr(el, "text", None) or el.attr("aria-label") or el.attr("title") or "") .replace("\n", " ") .strip() .lower() ) except Exception: continue if not label or "cookie settings" in label or "cookies settings" in label: continue if label in ("filter icon", "clear", "apply", "back button", "cancel", "search…", "search..."): continue for rank, key in enumerate(prefer_keys): if key == label or key in label: if rank < best_rank: best = el best_label = label best_rank = rank break if best is not None: for mode in ("click", "js", "actions"): try: if mode == "click": best.click() elif mode == "js": best.click(by_js=True) else: page.actions.click(best) return f"clicked:{best_label[:40]}" except Exception: continue # 3) Visible text match (DrissionPage text locator fallback) for text in native_texts: el = None for loc in (f"text:{text}", f"tag:button@@text():{text}", f"@text():{text}"): try: el = page.ele(loc, timeout=0.15) except Exception: el = None if el is not None: break if el is None: continue try: el.click() return f"clicked:{text}" except Exception: try: el.click(by_js=True) return f"clicked-js:{text}" except Exception: try: page.actions.click(el) return f"clicked-actions:{text}" except Exception: pass return "none" def _js_click_once() -> str: try: return str( page.run_js( r""" function isVisible(node) { if (!node) return false; try { const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } catch (e) { return false; } } function labelOf(n) { return ((n.innerText || n.textContent || n.getAttribute('aria-label') || n.getAttribute('title') || '') + '') .replace(/\s+/g, ' ').trim().toLowerCase(); } const prefer = [ 'accept all cookies', 'accept all', 'allow all', 'allow all cookies', 'confirm my choices', 'confirm my choice', 'save preferences', 'reject all', 'reject all cookies', '接受所有 cookie', '接受所有', '接受全部', '全部接受', '允许全部', '确认我的选择', '拒绝全部', '全部拒绝' ]; for (const sel of [ '#onetrust-accept-btn-handler','#accept-recommended-btn-handler', '#onetrust-reject-all-handler','.ot-pc-refuse-all-handler', '.save-preference-btn-handler','button.save-preference-btn-handler', '.onetrust-close-btn-handler','#close-pc-btn-handler' ]) { const el = document.querySelector(sel); if (el && isVisible(el) && !el.disabled) { try { el.click(); return 'clicked:' + sel; } catch (e) {} } } const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')); for (const t of prefer) { const target = nodes.find((n) => { if (!isVisible(n) || n.disabled) return false; const label = labelOf(n); if (!label || label.includes('cookie settings') || label.includes('cookies settings')) return false; if (['filter icon','clear','apply','back button','cancel'].includes(label)) return false; return label === t || label.includes(t); }); if (target) { try { target.click(); return 'clicked:' + ((target.innerText||t)+'').trim().slice(0,40); } catch(e) {} } } return 'none'; """ ) or "none" ) except Exception as exc: if log_callback: log_callback(f"[Debug] cookie js dismiss err: {exc}") return "err" def _force_hide() -> str: try: page.run_js( r""" try { document.querySelectorAll( '#onetrust-banner-sdk, #onetrust-consent-sdk, .onetrust-pc-dark-filter, #onetrust-pc-sdk, #ot-sdk-btn-floating, [class*="cookie-banner"], [id*="cookie-banner"]' ).forEach((el) => { try { el.style.setProperty('display', 'none', 'important'); el.style.setProperty('visibility', 'hidden', 'important'); el.style.setProperty('pointer-events', 'none', 'important'); el.setAttribute('aria-hidden', 'true'); } catch (e) {} }); document.documentElement && document.documentElement.classList.remove('ot-fade-in', 'onetrust-no-scroll'); if (document.body) { document.body.classList.remove('ot-fade-in', 'onetrust-no-scroll'); document.body.style.overflow = ''; } } catch (e) {} """ ) return "force-hide" except Exception: return "force-hide-failed" def _seed_optanon_cookies() -> None: """Best-effort: mark OneTrust already accepted so banner/PC does not block CF.""" try: page.run_js( r""" try { const domains = [location.hostname, '.x.ai', '.accounts.x.ai']; const now = new Date(); const exp = new Date(now.getTime() + 365*24*3600*1000).toUTCString(); const pairs = [ 'OptanonAlertBoxClosed=' + now.toISOString(), // groups: necessary + performance/functional/targeting commonly "1" 'OptanonConsent=isGpcEnabled=0&datestamp=' + encodeURIComponent(now.toString()) + '&version=202401.1.0&isIABGlobal=false&hosts=&consentId=auto&interactionCount=1&landingPath=NotLandingPage&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A1&AwaitingReconsent=false' ]; for (const d of domains) { for (const p of pairs) { try { document.cookie = p + '; path=/; domain=' + d + '; expires=' + exp + '; SameSite=Lax'; } catch (e) {} try { document.cookie = p + '; path=/; expires=' + exp + '; SameSite=Lax'; } catch (e) {} } } try { if (window.OneTrust && typeof OneTrust.AllowAll === 'function') OneTrust.AllowAll(); } catch (e) {} try { if (window.OneTrust && typeof OneTrust.Close === 'function') OneTrust.Close(); } catch (e) {} } catch (e) {} """ ) except Exception: pass present = _cookie_still_present() # Seed consent cookies first — often enough for OneTrust to stop re-opening PC. _seed_optanon_cookies() human_sleep(0.15) # Even if presence probe is flaky, still try a few click rounds when called # from profile/signup — historical success always got "Accept All Cookies". any_ok = False clicked_label = "" max_rounds = 5 if present else 3 for round_i in range(max_rounds): if round_i > 0 and not _cookie_still_present() and any_ok: break result = _native_click_once() if result == "none": result = _js_click_once() # After first real click attempt, also force-hide residual PC/banner if result.startswith("clicked") or (result in ("none", "err") and round_i >= 1): if result.startswith("clicked"): any_ok = True clicked_label = result.split(":", 1)[-1] if log_callback and round_i == 0: log_callback(f"[*] 已关闭 Cookie 弹窗: {clicked_label}") _seed_optanon_cookies() _force_hide() any_ok = True human_sleep(0.3) # Stop after one successful accept + hide — do not keep re-clicking if clicked_label or not _cookie_still_present(): break else: human_sleep(0.12) # Final hard hide — remove entire OneTrust tree so it cannot re-open mid-CF try: page.run_js( r""" try { ['#onetrust-consent-sdk','#onetrust-banner-sdk','#onetrust-pc-sdk','.onetrust-pc-dark-filter','#ot-sdk-btn-floating'] .forEach((sel) => { document.querySelectorAll(sel).forEach((el) => { try { el.remove(); } catch (e) { try { el.style.setProperty('display','none','important'); el.style.setProperty('visibility','hidden','important'); el.style.setProperty('pointer-events','none','important'); } catch (e2) {} } }); }); if (document.body) { document.body.classList.remove('ot-fade-in','onetrust-no-scroll'); document.body.style.overflow = ''; } } catch (e) {} """ ) any_ok = True except Exception: if _cookie_still_present(): _force_hide() any_ok = True try: page._grok_cookie_dismissed = True except Exception: pass return any_ok def open_signup_page(log_callback=None, cancel_callback=None): browser = _get_browser() page = _get_page() raise_if_cancelled(cancel_callback) if browser is None: browser, page = start_browser(log_callback=log_callback) if log_callback: log_callback("[*] 浏览器已启动") # Ensure CDP stealth is on this tab before navigating to CF-protected pages apply_stealth_patches(_get_page(), log_callback=log_callback) try: page = _get_page() page.get(SIGNUP_URL) except Exception as e: if log_callback: log_callback(f"[Debug] 打开URL异常: {e}") try: TabPool.release_tab() browser, page = start_browser(log_callback=log_callback) page.get(SIGNUP_URL) except Exception as e2: if log_callback: log_callback(f"[Debug] 创建新标签页异常: {e2}") restart_browser(log_callback=log_callback) page = _get_page() page.get(SIGNUP_URL) page = _get_page() apply_stealth_patches(page, log_callback=None) page.wait.doc_loaded() # Cookie consent often blocks Sign up / email submit (see network 403 on /mp/track is unrelated) dismiss_cookie_banner(page, log_callback=log_callback) dump_state(page, "signup-loaded") take_screenshot(page, "signup") human_sleep(1.2, cancel_callback) dismiss_cookie_banner(page, log_callback=None) if log_callback: log_callback(f"[*] 当前URL: {page.url}") click_email_signup_button( log_callback=log_callback, cancel_callback=cancel_callback ) dismiss_cookie_banner(_get_page(), log_callback=log_callback) dump_state(page, "after-email-signup-click") def has_profile_form(log_callback=None): page = refresh_active_page() try: return bool( page.run_js( """ const givenInput = document.querySelector('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"]'); const familyInput = document.querySelector('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"]'); const passwordInput = document.querySelector('input[data-testid="password"], input[name="password"], input[type="password"]'); return !!(givenInput && familyInput && passwordInput); """ ) ) except Exception: return False def fill_email_and_submit(timeout=15, log_callback=None, cancel_callback=None): page = _get_page() raise_if_cancelled(cancel_callback) check_timeout(time.time()) # Cookie modal blocks the real Sign up button click dismiss_cookie_banner(page, log_callback=log_callback) email, dev_token = get_email_and_token() if not email or not dev_token: raise Exception("获取邮箱失败") if log_callback: log_callback(f"[*] 已创建邮箱: {email}") deadline = time.time() + timeout while time.time() < deadline: raise_if_cancelled(cancel_callback) dismiss_cookie_banner(page, log_callback=None) filled = page.run_js( """ const email = arguments[0]; function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const input = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"]')).find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null; if (!input) return 'not-ready'; input.focus(); input.click(); // 清空并设置值 const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; const tracker = input._valueTracker; if (tracker) tracker.setValue(''); if (valueSetter) valueSetter.call(input, email); else input.value = email; // 完整事件序列,确保 React 受控组件同步 input.dispatchEvent(new Event('focus', { bubbles: true })); input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: email, inputType: 'insertText' })); input.dispatchEvent(new InputEvent('input', { bubbles: true, data: email, inputType: 'insertText' })); input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('blur', { bubbles: true })); // 验证:值已写入即可(不依赖 checkValidity,部分站点自定义校验会导致误判) const current = (input.value || '').trim(); if (current === email) return 'filled'; // 兜底:尝试逐字符输入 input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); for (const ch of email) { input.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true })); input.value += ch; input.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })); input.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true })); } input.dispatchEvent(new Event('change', { bubbles: true })); if ((input.value || '').trim() === email) return 'filled'; return input.value; """, email, ) if filled == "not-ready": human_sleep(0.5, cancel_callback) continue if filled != "filled": if log_callback: log_callback(f"[Debug] 邮箱输入框已出现,但写入失败: {filled}") human_sleep(0.5, cancel_callback) continue human_sleep(0.8, cancel_callback) clicked = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const input = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"]')).find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null; // Prefer presence of non-empty value over checkValidity(): custom validation can false-negative. if (!input || !(input.value || '').trim()) return 'no-input-value'; const buttons = Array.from(document.querySelectorAll('button[type="submit"], button')).filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'); const submitButton = buttons.find((node) => { // Keep both spaced and unspaced forms: we strip whitespace for matching. const raw = (node.innerText || node.textContent || '').trim(); const compact = raw.replace(/\s+/g, '').toLowerCase(); return ( raw.includes('注册') || compact === 'signup' || compact.includes('signup') || compact.includes('continue') || compact.includes('next') || compact.includes('createaccount') || compact.includes('submit') ); }); if (!submitButton) { return 'no-submit:' + buttons.map((b) => (b.innerText || '').trim()).filter(Boolean).slice(0, 8).join('|'); } if (submitButton.disabled) return 'submit-disabled'; submitButton.click(); return 'clicked'; """ ) if clicked in (True, "clicked", "true", 1): if log_callback: log_callback(f"[*] 已填写邮箱并点击注册: {email}") # Wait for transition to OTP / next step (email form should leave or code inputs appear) try: confirm_sec = max(8, int(config.get("email_submit_confirm_timeout", 30) or 30)) except Exception: confirm_sec = 30 transit_deadline = time.time() + confirm_sec last_state = "waiting" reclicked = False cf_tried = False while time.time() < transit_deadline: raise_if_cancelled(cancel_callback) try: state = page.run_js(r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const codeInput = Array.from(document.querySelectorAll( 'input[data-input-otp="true"], input[name="code"], input[autocomplete="one-time-code"], input[inputmode="numeric"], input[name="verificationCode"]' )).find((n) => isVisible(n)); if (codeInput) return 'code-ready'; const emailInput = Array.from(document.querySelectorAll( 'input[data-testid="email"], input[name="email"], input[type="email"]' )).find((n) => isVisible(n)); if (!emailInput) return 'email-gone'; const err = Array.from(document.querySelectorAll('[role="alert"], .error, [data-testid*="error"], [class*="error"]')) .map((n) => (n.innerText || '').trim()).filter(Boolean)[0] || ''; if (err) return 'error:' + err.slice(0, 120); const bodyTxt = (document.body && (document.body.innerText || '')) || ''; // xAI toast bubbles: [permission_denied] HTTP 403 if (/permission_denied|HTTP\s*403/i.test(bodyTxt)) return 'error:permission_denied HTTP 403'; if (/rate.?limit|too many|try again later|请求过多|稍后再试/i.test(bodyTxt)) return 'rate-limited'; const hasCf = !!document.querySelector( 'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], input[name="cf-turnstile-response"], div.cf-turnstile, [data-sitekey]' ); if (hasCf) return 'cf-challenge'; return 'waiting'; """) except Exception: state = "waiting" last_state = str(state or "waiting") if isinstance(state, str) and state.startswith("error:"): if log_callback: log_callback(f"[Debug] 邮箱提交后页面报错: {state}") raise Exception(f"邮箱提交被拒绝: {state[6:]}") if state == "rate-limited": raise Exception("邮箱提交触发限流 rate-limited") if state in ("code-ready", "email-gone"): if log_callback: log_callback(f"[*] 邮箱提交后页面状态: {state}") dump_state(page, "email-submitted") take_screenshot(page, "email-submitted") return email, dev_token # CF on email step: try turnstile once then continue waiting if state == "cf-challenge" and not cf_tried: cf_tried = True if log_callback: log_callback("[*] 邮箱步骤出现 Cloudflare,尝试通过 Turnstile...") try: getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback, max_rounds=5) except Exception as cf_exc: if log_callback: log_callback(f"[Debug] 邮箱步骤 Turnstile 未过: {cf_exc}") # Still on email form after a few seconds: re-click submit once elapsed = confirm_sec - max(0.0, transit_deadline - time.time()) if not reclicked and elapsed >= 4.0 and state in ("waiting", "cf-challenge"): reclicked = True dismiss_cookie_banner(page, log_callback=log_callback) try: again = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const buttons = Array.from(document.querySelectorAll('button[type="submit"], button')) .filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'); const submitButton = buttons.find((node) => { const raw = (node.innerText || node.textContent || '').trim(); const compact = raw.replace(/\s+/g, '').toLowerCase(); return raw.includes('注册') || compact.includes('signup') || compact.includes('continue') || compact.includes('next') || compact.includes('submit') || compact.includes('createaccount'); }); if (!submitButton) return 'no-submit'; submitButton.click(); return 'reclicked'; """ ) if log_callback: log_callback(f"[*] 邮箱表单仍在,二次点击提交: {again}") except Exception as re_exc: if log_callback: log_callback(f"[Debug] 二次提交失败: {re_exc}") human_sleep(0.6, cancel_callback) # Timed out without reaching code step — do NOT poll mail (email never sent) try: url_now = getattr(page, "url", "") or "" except Exception: url_now = "" dump_state(page, "email-submit-stuck") take_screenshot(page, "email-submit-stuck") raise Exception( f"邮箱提交后未进入验证码页 (state={last_state}, url={url_now[:120]})" ) if log_callback and clicked not in (False, None, ""): log_callback(f"[Debug] 邮箱已写入,但注册按钮未点到: {clicked}") human_sleep(0.5, cancel_callback) raise Exception("未找到邮箱输入框或注册按钮") def fill_code_and_submit(email, dev_token, timeout=180, log_callback=None, cancel_callback=None): page = _get_page() check_timeout(time.time()) dump_state(page, "wait-code") take_screenshot(page, "wait-code") def _resend_code(): page.run_js( r""" const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')); const target = nodes.find((node) => { const t = (node.innerText || node.textContent || '').replace(/\s+/g, '').toLowerCase(); return t.includes('重新发送') || t.includes('resend') || t.includes('再次发送'); }); if (target && !target.disabled) { target.click(); return true; } return false; """ ) try: mail_timeout = int(config.get("mail_timeout", timeout) or timeout) except Exception: mail_timeout = timeout try: mail_poll_interval = float(config.get("mail_poll_interval", 3) or 3) except Exception: mail_poll_interval = 3 code = get_oai_code( dev_token, email, timeout=mail_timeout, poll_interval=mail_poll_interval, log_callback=log_callback, cancel_callback=cancel_callback, resend_callback=_resend_code, ) if not code: raise Exception("获取验证码失败") clean_code = str(code).replace("-", "").strip() deadline = time.time() + timeout while time.time() < deadline: raise_if_cancelled(cancel_callback) filled = page.run_js( """ const code = String(arguments[0] || '').trim(); if (!code) return 'empty-code'; function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function setInputValue(input, value) { const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; const tracker = input._valueTracker; if (tracker) tracker.setValue(''); if (nativeSetter) nativeSetter.call(input, value); else input.value = value; input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' })); input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' })); input.dispatchEvent(new Event('change', { bubbles: true })); } const aggregate = Array.from(document.querySelectorAll( 'input[data-input-otp=\"true\"], input[name=\"code\"], input[autocomplete=\"one-time-code\"], input[inputmode=\"numeric\"], input[inputmode=\"text\"]' )).find((node) => isVisible(node) && !node.disabled && !node.readOnly && Number(node.maxLength || 6) > 1); if (aggregate) { aggregate.focus(); aggregate.click(); setInputValue(aggregate, code); return String(aggregate.value || '').replace(/\\s+/g, '') ? 'filled-aggregate' : 'aggregate-failed'; } const otpBoxes = Array.from(document.querySelectorAll('input')).filter((node) => { if (!isVisible(node) || node.disabled || node.readOnly) return false; const maxLength = Number(node.maxLength || 0); const ac = String(node.autocomplete || '').toLowerCase(); return maxLength === 1 || ac === 'one-time-code'; }); if (otpBoxes.length >= code.length) { for (let i = 0; i < code.length; i += 1) { const ch = code[i] || ''; const box = otpBoxes[i]; box.focus(); box.click(); setInputValue(box, ch); box.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })); box.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })); } const merged = otpBoxes.slice(0, code.length).map((x) => String(x.value || '').trim()).join(''); return merged.length ? 'filled-boxes' : 'boxes-failed'; } return 'not-ready'; """, clean_code, ) if filled == "not-ready": human_sleep(0.5, cancel_callback) continue if "failed" in str(filled): if log_callback: log_callback(f"[Debug] 验证码填写失败: {filled}") human_sleep(0.5, cancel_callback) continue clicked = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const buttons = Array.from(document.querySelectorAll('button[type=\"submit\"], button')).filter((node) => { return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'; }); const btn = buttons.find((node) => { const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase(); return ( t.includes('确认邮箱') || t.includes('继续') || t.includes('下一步') || t.includes('confirm') || t.includes('continue') || t.includes('next') ); }); if (!btn) return 'no-button'; btn.focus(); btn.click(); return 'clicked'; """ ) if clicked == "clicked" or clicked == "no-button": if log_callback: log_callback(f"[*] 已填写验证码并提交: {code}") human_sleep(1.5, cancel_callback) return code human_sleep(0.5, cancel_callback) raise Exception("验证码已获取,但自动填写/提交失败") def getTurnstileToken(log_callback=None, cancel_callback=None, max_rounds: int = 20): page = _get_page() if page is None: raise Exception("页面未就绪,无法执行 Turnstile") try: page.run_js( "try { if (window.turnstile && typeof turnstile.reset === 'function') turnstile.reset(); } catch(e) {}" ) except Exception: pass rounds = max(1, int(max_rounds or 20)) for _ in range(0, rounds): raise_if_cancelled(cancel_callback) try: token = page.run_js( """ try { const byInput = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim(); if (byInput) return byInput; if (window.turnstile && typeof turnstile.getResponse === 'function') { return String(turnstile.getResponse() || '').trim(); } return ''; } catch(e) { return ''; } """ ) token = str(token or "").strip() if len(token) >= 80: if log_callback: log_callback(f"[Debug] Turnstile 已通过,token长度={len(token)}") return token challenge_input = page.ele("@name=cf-turnstile-response") if challenge_input: wrapper = challenge_input.parent() iframe = None try: iframe = wrapper.shadow_root.ele("tag:iframe") except Exception: iframe = None if iframe: try: iframe.run_js( """ window.dtp = 1; function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } let sx = getRandomInt(800, 1200); let sy = getRandomInt(400, 700); Object.defineProperty(MouseEvent.prototype, 'screenX', { value: sx }); Object.defineProperty(MouseEvent.prototype, 'screenY', { value: sy }); """ ) except Exception: pass try: body_sr = iframe.ele("tag:body").shadow_root btn = body_sr.ele("tag:input") if btn: btn.click() except Exception: pass else: # 兜底:尝试触发页面上可见的 Turnstile 容器 page.run_js( """ const nodes = Array.from(document.querySelectorAll('div,span,iframe')).filter((n) => { const txt = (n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || ''); return String(txt).toLowerCase().includes('turnstile'); }); if (nodes.length && typeof nodes[0].click === 'function') nodes[0].click(); """ ) except Exception: pass sleep_with_cancel(1, cancel_callback) raise Exception("Turnstile 获取 token 失败") def build_profile(): given_name_pool = [ "Neo", "Ethan", "Liam", "Noah", "Lucas", "Mason", "Ryan", "Leo", "Owen", "Aiden", "Elio", "Aron", "Ivan", "Nolan", "Evan", "Kai", "Caleb", "Adam", "Ezra", "Miles", "Logan", "Carter", "Hunter", "Jason", "Brian", "Dylan", "Alex", "Colin", "Blake", "Gavin", "Henry", "Julian", "Kevin", "Louis", "Marcus", "Nathan", "Oscar", "Peter", "Quinn", "Robin", "Simon", "Tristan", "Victor", "Wesley", "Xavier", "Yuri", "Zane", "Felix", "Aaron", "Damian", ] family_name_pool = [ "Lin", "Wang", "Zhao", "Liu", "Chen", "Zhang", "Xu", "Sun", "Guo", "He", "Yang", "Wu", "Zhou", "Tang", "Qin", "Shi", "Fang", "Peng", "Cao", "Deng", "Fan", "Fu", "Gao", "Han", "Hu", "Jiang", "Kong", "Lu", "Ma", "Nie", "Pan", "Qiao", "Ren", "Shao", "Tian", "Xie", "Yan", "Yao", "Yu", "Zeng", "Bai", "Duan", "Hou", "Jin", "Kang", "Luo", "Mao", "Song", "Wei", "Xiong", ] given_name = random.choice(given_name_pool) family_name = random.choice(family_name_pool) password = "N" + secrets.token_hex(4) + "!a7#" + secrets.token_urlsafe(6) return given_name, family_name, password def fill_profile_and_submit(timeout=120, log_callback=None, cancel_callback=None): page = _get_page() check_timeout(time.time()) dump_state(page, "profile-form") take_screenshot(page, "profile-form") given_name, family_name, password = build_profile() if log_callback: log_callback("[*] 预热 Turnstile...") human_sleep(2, cancel_callback) deadline = time.time() + timeout form_filled_once = False wait_cf_since = None last_cf_retry_at = 0.0 while time.time() < deadline: raise_if_cancelled(cancel_callback) if not form_filled_once: filled = page.run_js( """ const givenName = arguments[0]; const familyName = arguments[1]; const password = arguments[2]; function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function pickInput(selector) { return Array.from(document.querySelectorAll(selector)).find((node) => { return isVisible(node) && !node.disabled && !node.readOnly; }) || null; } function setInputValue(input, value) { if (!input) return false; input.focus(); input.click(); const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; const tracker = input._valueTracker; if (tracker) tracker.setValue(''); if (nativeSetter) nativeSetter.call(input, value); else input.value = value; input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' })); input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' })); input.dispatchEvent(new Event('change', { bubbles: true })); input.blur(); return String(input.value || '').trim() === String(value || '').trim(); } const givenInput = pickInput('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"], input[aria-label*="名"]'); const familyInput = pickInput('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"], input[aria-label*="姓"]'); const passwordInput = pickInput('input[data-testid="password"], input[name="password"], input[type="password"], input[autocomplete="new-password"]'); if (!givenInput || !familyInput || !passwordInput) return 'not-ready'; const ok1 = setInputValue(givenInput, givenName); const ok2 = setInputValue(familyInput, familyName); const ok3 = setInputValue(passwordInput, password); if (!ok1 || !ok2 || !ok3) return 'fill-failed'; const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => { return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'; }); const submitBtn = buttons.find((node) => { const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase(); return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount'); }); // 必须等待 Cloudflare 校验通过后再提交 const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]'); if (cfPresent) { const token = String((cfInput && cfInput.value) || '').trim(); const solvedByToken = token.length >= 80; if (!solvedByToken) return 'wait-cloudflare:' + token.length; } if (submitBtn) { return 'ready-to-submit'; } return 'filled-no-submit'; """, given_name, family_name, password, ) if isinstance(filled, str) and filled.startswith("wait-cloudflare"): form_filled_once = True if log_callback: token_len = filled.split(":", 1)[1] if ":" in filled else "0" log_callback(f"[Debug] 资料已填写,等待 Cloudflare 人机验证通过... 当前token长度={token_len}") if token_len == "0": pause_seconds = random.uniform(1, 3) if log_callback: log_callback(f"[Debug] Cloudflare token 为空,暂停 {pause_seconds:.1f}s 后继续检测") sleep_with_cancel(pause_seconds, cancel_callback) now = time.time() if wait_cf_since is None: wait_cf_since = now # 卡住后自动二次复用 Turnstile 组件 if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8: if log_callback: log_callback("[Debug] Cloudflare 验证卡住,开始二次复用 Turnstile...") try: token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback) if token: synced = page.run_js( """ const token = String(arguments[0] || '').trim(); const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); if (!cfInput || !token) return false; const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (nativeSetter) nativeSetter.call(cfInput, token); else cfInput.value = token; cfInput.dispatchEvent(new Event('input', { bubbles: true })); cfInput.dispatchEvent(new Event('change', { bubbles: true })); return String(cfInput.value || '').trim().length; """, token, ) if log_callback: log_callback(f"[Debug] Turnstile 二次复用完成,回填长度={synced}") except Exception as cf_exc: if log_callback: log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}") last_cf_retry_at = now sleep_with_cancel(0.8, cancel_callback) continue if filled in ("ready-to-submit", "filled-no-submit"): form_filled_once = True elif filled == "fill-failed" and log_callback: log_callback("[Debug] 资料输入失败,重试中...") sleep_with_cancel(0.5, cancel_callback) continue elif filled == "not-ready": sleep_with_cancel(0.5, cancel_callback) continue submit_state = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]'); if (cfPresent) { const token = String((cfInput && cfInput.value) || '').trim(); const solvedByToken = token.length >= 80; if (!solvedByToken) return 'wait-cloudflare:' + token.length; } function buttonText(node) { return [ node.innerText, node.textContent, node.getAttribute('value'), node.getAttribute('aria-label'), node.getAttribute('title'), ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => { return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'; }); const submitBtn = buttons.find((node) => { const t = buttonText(node).replace(/\s+/g, '').toLowerCase(); return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount'); }); if (!submitBtn) { const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | '); return 'no-submit-button:' + visibleTexts; } submitBtn.focus(); submitBtn.click(); return 'submitted'; """ ) if isinstance(submit_state, str) and submit_state.startswith("wait-cloudflare"): if log_callback: token_len = submit_state.split(":", 1)[1] if ":" in submit_state else "0" log_callback(f"[Debug] 等待 Cloudflare 人机验证通过后再提交... 当前token长度={token_len}") now = time.time() if wait_cf_since is None: wait_cf_since = now if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8: if log_callback: log_callback("[Debug] 提交前仍卡住,自动再次复用 Turnstile...") try: token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback) if token: synced = page.run_js( """ const token = String(arguments[0] || '').trim(); const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); if (!cfInput || !token) return false; const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (nativeSetter) nativeSetter.call(cfInput, token); else cfInput.value = token; cfInput.dispatchEvent(new Event('input', { bubbles: true })); cfInput.dispatchEvent(new Event('change', { bubbles: true })); return String(cfInput.value || '').trim().length; """, token, ) if log_callback: log_callback(f"[Debug] Turnstile 二次复用完成,回填长度={synced}") except Exception as cf_exc: if log_callback: log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}") last_cf_retry_at = now sleep_with_cancel(0.8, cancel_callback) continue if submit_state == "submitted": if log_callback: log_callback(f"[Debug] 已填写注册资料并提交: {given_name} {family_name}") return {"given_name": given_name, "family_name": family_name, "password": password} wait_cf_since = None if isinstance(submit_state, str) and submit_state.startswith("no-submit-button") and log_callback: visible_buttons = submit_state.split(":", 1)[1] if ":" in submit_state else "" suffix = f" 可见按钮: {visible_buttons}" if visible_buttons else "" log_callback(f"[Debug] 未找到提交按钮,继续等待页面稳定...{suffix}") sleep_with_cancel(0.5, cancel_callback) raise Exception("最终注册页资料填写失败") def generate_random_birthdate(): """生成随机生日(20-40 岁)。""" import datetime as dt today = dt.date.today() age = random.randint(20, 40) birth_year = today.year - age birth_month = random.randint(1, 12) birth_day = random.randint(1, 28) return f"{birth_year}-{birth_month:02d}-{birth_day:02d}T16:00:00.000Z" def set_birth_date(session, log_callback=None): """设置生日。""" url = "https://grok.com/rest/auth/set-birth-date" new_headers = { "content-type": "application/json", "origin": "https://grok.com", "referer": "https://grok.com/", } payload = {"birthDate": generate_random_birthdate()} try: res = session.post(url, json=payload, headers=new_headers, timeout=15) if log_callback: log_callback(f"[Debug] set_birth_date status: {res.status_code}") if 200 <= res.status_code < 300: return True, "ok" return False, f"set_birth_date HTTP {res.status_code}" except Exception as e: if log_callback: log_callback(f"[set_birth_date] 异常: {e}") return False, f"set_birth_date 异常: {e}" def set_tos_accepted(session, log_callback=None): """同意 TOS。""" url = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion" payload = struct.pack("B", (2 << 3) | 0) + struct.pack("B", 1) data = b"\x00" + struct.pack(">I", len(payload)) + payload new_headers = { "content-type": "application/grpc-web+proto", "x-grpc-web": "1", "x-user-agent": "connect-es/2.1.1", "origin": "https://accounts.x.ai", "referer": "https://accounts.x.ai/accept-tos", } try: res = session.post(url, data=data, headers=new_headers, timeout=15) if log_callback: log_callback(f"[Debug] set_tos_accepted status: {res.status_code}") if 200 <= res.status_code < 300: return True, "ok" return False, f"set_tos_accepted HTTP {res.status_code}" except Exception as e: if log_callback: log_callback(f"[set_tos_accepted] 异常: {e}") return False, f"set_tos_accepted 异常: {e}" def encode_grpc_nsfw_settings(): """编码 NSFW 设置 gRPC 请求体。""" field1_content = bytes([0x10, 0x01]) field1 = bytes([0x0A, len(field1_content)]) + field1_content nsfw_string = b"always_show_nsfw_content" field2_inner = bytes([0x0A, len(nsfw_string)]) + nsfw_string field2 = bytes([0x12, len(field2_inner)]) + field2_inner payload = field1 + field2 return b"\x00" + struct.pack(">I", len(payload)) + payload def update_nsfw_settings(session, log_callback=None): """更新 NSFW 设置。""" url = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls" data = encode_grpc_nsfw_settings() new_headers = { "content-type": "application/grpc-web+proto", "x-grpc-web": "1", "origin": "https://grok.com", "referer": "https://grok.com/", } try: res = session.post(url, data=data, headers=new_headers, timeout=15) if log_callback: log_callback(f"[Debug] update_nsfw status: {res.status_code}") if 200 <= res.status_code < 300: return True, "ok" return False, f"update_nsfw_settings HTTP {res.status_code}" except Exception as e: if log_callback: log_callback(f"[update_nsfw] 异常: {e}") return False, f"update_nsfw_settings 异常: {e}" def enable_nsfw(sso_cookie, log_callback=None): """使用 sso cookie 自动开启 NSFW(生日 + TOS + NSFW 设置)。""" from curl_cffi import requests as cf_requests session = cf_requests.Session() session.cookies.set("sso", sso_cookie, domain="grok.com") session.cookies.set("sso", sso_cookie, domain="accounts.x.ai") results = {} # 1. 设置生日 ok, msg = set_birth_date(session, log_callback=log_callback) results["set_birth_date"] = {"ok": ok, "msg": msg} # 2. 同意 TOS ok, msg = set_tos_accepted(session, log_callback=log_callback) results["set_tos_accepted"] = {"ok": ok, "msg": msg} # 3. 开启 NSFW ok, msg = update_nsfw_settings(session, log_callback=log_callback) results["update_nsfw_settings"] = {"ok": ok, "msg": msg} if log_callback: all_ok = all(r["ok"] for r in results.values()) log_callback(f"[*] NSFW 设置: {'全部成功' if all_ok else results}") return results # ── wait_for_sso_cookie ── def wait_for_sso_cookie(timeout=120, log_callback=None, cancel_callback=None): deadline = time.time() + timeout last_seen_names = set() last_submit_retry = 0.0 last_cf_retry_at = 0.0 while time.time() < deadline: raise_if_cancelled(cancel_callback) try: page = _get_page() if page is None: human_sleep(1, cancel_callback) continue # 仍停留在“完成注册”页时,若 Cloudflare 已通过,周期性重试点击提交 now = time.time() if now - last_submit_retry >= 2.5: retried = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const titleHit = !!Array.from(document.querySelectorAll('h1,h2,div,span')).find((el) => { const t = (el.textContent || '').replace(/\s+/g, ''); return t.includes('完成注册'); }); if (!titleHit) return 'not-final-page'; const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]'); if (cfPresent) { const token = String((cfInput && cfInput.value) || '').trim(); const solved = token.length >= 80; if (!solved) return 'final-page-wait-cf:' + token.length; } const buttons = Array.from(document.querySelectorAll('button[type="submit"], button')).filter((node) => { return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'; }); const submitBtn = buttons.find((node) => { const t = (node.innerText || node.textContent || '').replace(/\s+/g, '').toLowerCase(); return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('signup') || t.includes('createaccount'); }); if (!submitBtn) return 'final-page-no-submit'; submitBtn.focus(); submitBtn.click(); return 'final-page-clicked-submit'; """ ) last_submit_retry = now if log_callback and retried in ("final-page-no-submit", "final-page-clicked-submit"): log_callback(f"[Debug] 最终页状态: {retried}") if log_callback and isinstance(retried, str) and retried.startswith("final-page-wait-cf"): token_len = retried.split(":", 1)[1] if ":" in retried else "0" log_callback(f"[Debug] 最终页状态: final-page-wait-cf, token长度={token_len}") if now - last_cf_retry_at >= 10: if log_callback: log_callback("[*] 最终页 Cloudflare 卡住,自动二次复用 Turnstile...") try: token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback) if token: synced = page.run_js( """ const token = String(arguments[0] || '').trim(); const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); if (!cfInput || !token) return false; const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (nativeSetter) nativeSetter.call(cfInput, token); else cfInput.value = token; cfInput.dispatchEvent(new Event('input', { bubbles: true })); cfInput.dispatchEvent(new Event('change', { bubbles: true })); return String(cfInput.value || '').trim().length; """, token, ) if log_callback: log_callback(f"[*] 最终页 Turnstile 二次复用完成,回填长度={synced}") except Exception as cf_exc: if log_callback: log_callback(f"[Debug] 最终页 Turnstile 二次复用失败: {cf_exc}") last_cf_retry_at = now cookies = page.cookies(all_domains=True, all_info=True) or [] for item in cookies: if isinstance(item, dict): name = str(item.get("name", "")).strip() value = str(item.get("value", "")).strip() else: name = str(getattr(item, "name", "")).strip() value = str(getattr(item, "value", "")).strip() if name: last_seen_names.add(name) if name in ("sso", "sso-rw") and value: if log_callback: log_callback(f"[*] 已获取到 {name} cookie") return value except PageDisconnectedError: refresh_active_page() except Exception: pass human_sleep(1, cancel_callback) raise Exception( f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}" ) # ── 登录(非注册)获取 sso ── LOGIN_URL = "https://accounts.x.ai/login?redirect=grok-com" def open_login_page(log_callback=None, cancel_callback=None): """打开 xAI 登录页,点击「使用邮箱登录」。""" browser = _get_browser() page = _get_page() raise_if_cancelled(cancel_callback) if browser is None: browser, page = start_browser() if log_callback: log_callback("[*] 浏览器已启动") try: page = _get_page() page.get(LOGIN_URL) except Exception as e: if log_callback: log_callback(f"[Debug] 打开URL异常: {e}") restart_browser() page = _get_page() page.get(LOGIN_URL) page.wait.doc_loaded() human_sleep(2, cancel_callback) if log_callback: log_callback(f"[*] 当前URL: {page.url}") # 点击「使用邮箱登录」 clicked = page.run_js(""" const btn = document.querySelector('button[data-testid="continue-with-email"]'); if (btn) { btn.click(); return 'clicked'; } return 'not-found'; """) if clicked != 'clicked': raise Exception("未找到「使用邮箱登录」按钮") human_sleep(2, cancel_callback) if log_callback: log_callback("[*] 已点击「使用邮箱登录」") def fill_login_and_submit(email, password, timeout=120, log_callback=None, cancel_callback=None): """两步登录:1.填邮箱点下一步 2.填密码处理Turnstile点登录。""" page = _get_page() deadline = time.time() + timeout last_cf_retry = 0.0 # ── 步骤1:填邮箱,点「下一步」 ── email_submitted = False while time.time() < deadline and not email_submitted: raise_if_cancelled(cancel_callback) state = page.run_js(""" const emailInput = document.querySelector('input[data-testid="email"]'); if (!emailInput) return 'not-ready'; const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; const tracker = emailInput._valueTracker; if (tracker) tracker.setValue(''); if (ns) ns.call(emailInput, arguments[0]); else emailInput.value = arguments[0]; emailInput.dispatchEvent(new InputEvent('input', {bubbles:true, data:arguments[0], inputType:'insertText'})); emailInput.dispatchEvent(new Event('change', {bubbles:true})); emailInput.blur(); if (String(emailInput.value||'').trim() !== String(arguments[0]||'').trim()) return 'fill-failed'; const btn = document.querySelector('button[data-testid="sign-in-submit"]'); if (!btn) return 'no-btn'; if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'btn-disabled'; btn.click(); return 'submitted'; """, email) if state == 'submitted': email_submitted = True if log_callback: log_callback(f"[*] 已填写邮箱并提交: {email}") elif state == 'not-ready': human_sleep(0.5, cancel_callback) elif state == 'btn-disabled': human_sleep(0.5, cancel_callback) else: human_sleep(0.5, cancel_callback) if not email_submitted: raise Exception("邮箱提交超时") # 等密码框出现 human_sleep(2, cancel_callback) # ── 步骤2:填密码,处理 Turnstile,点「登录」 ── pw_filled = False while time.time() < deadline: raise_if_cancelled(cancel_callback) if not pw_filled: filled = page.run_js(""" const pwInput = document.querySelector('input[data-testid="password"]'); if (!pwInput) return 'not-ready'; const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; const tracker = pwInput._valueTracker; if (tracker) tracker.setValue(''); if (ns) ns.call(pwInput, arguments[0]); else pwInput.value = arguments[0]; pwInput.dispatchEvent(new InputEvent('input', {bubbles:true, data:arguments[0], inputType:'insertText'})); pwInput.dispatchEvent(new Event('change', {bubbles:true})); pwInput.blur(); if (String(pwInput.value||'').trim() !== String(arguments[0]||'').trim()) return 'fill-failed'; const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile'); if (cfPresent) { const token = String((cfInput && cfInput.value) || '').trim(); if (token.length < 80) return 'wait-cf:' + token.length; } return 'ready'; """, password) if isinstance(filled, str) and filled.startswith('wait-cf'): pw_filled = True if log_callback: token_len = filled.split(':',1)[1] if ':' in filled else '0' log_callback(f"[*] 已填密码,等待 Turnstile... token长度={token_len}") now = time.time() if now - last_cf_retry >= 8: try: token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback) if token: page.run_js(""" const token = String(arguments[0]||'').trim(); const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); if (cfInput && token) { const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (ns) ns.call(cfInput, token); else cfInput.value = token; cfInput.dispatchEvent(new Event('input', {bubbles:true})); cfInput.dispatchEvent(new Event('change', {bubbles:true})); } """, token) if log_callback: log_callback("[*] Turnstile 已通过,回填完成") except Exception as cf_exc: if log_callback: log_callback(f"[Debug] Turnstile 复用失败: {cf_exc}") last_cf_retry = now human_sleep(1, cancel_callback) continue elif filled == 'ready': pw_filled = True if log_callback: log_callback("[*] 密码已填写,准备提交") elif filled == 'not-ready': human_sleep(0.5, cancel_callback) continue elif filled == 'fill-failed': human_sleep(0.5, cancel_callback) continue # 提交 state = page.run_js(""" const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile'); if (cfPresent) { const token = String((cfInput && cfInput.value) || '').trim(); if (token.length < 80) return 'wait-cf:' + token.length; } const btn = document.querySelector('button[data-testid="sign-in-submit"]'); if (!btn) return 'no-submit'; if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'btn-disabled'; btn.click(); return 'submitted'; """) if isinstance(state, str) and state.startswith('wait-cf'): if log_callback: token_len = state.split(':',1)[1] if ':' in state else '0' log_callback(f"[*] 等待 Turnstile 通过后再提交... token长度={token_len}") now = time.time() if now - last_cf_retry >= 8: try: token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback) if token: page.run_js(""" const token = String(arguments[0]||'').trim(); const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); if (cfInput && token) { const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (ns) ns.call(cfInput, token); else cfInput.value = token; cfInput.dispatchEvent(new Event('input', {bubbles:true})); cfInput.dispatchEvent(new Event('change', {bubbles:true})); } """, token) if log_callback: log_callback("[*] Turnstile 二次复用完成") except Exception as cf_exc: if log_callback: log_callback(f"[Debug] Turnstile 复用失败: {cf_exc}") last_cf_retry = now human_sleep(1, cancel_callback) continue elif state == 'submitted': if log_callback: log_callback("[*] 已点击登录,等待 sso cookie...") return elif state == 'btn-disabled': human_sleep(1, cancel_callback) continue human_sleep(1, cancel_callback) raise Exception("登录提交超时") def login_and_get_sso(email, password, log_callback=None, cancel_callback=None): """完整登录流程:打开页 → 填邮箱密码 → Turnstile → 等 sso cookie。""" open_login_page(log_callback=log_callback, cancel_callback=cancel_callback) fill_login_and_submit(email, password, log_callback=log_callback, cancel_callback=cancel_callback) sso = wait_for_sso_cookie(timeout=120, log_callback=log_callback, cancel_callback=cancel_callback) return sso def export_cpa_after_success(email, password, sso, page=None, log_callback=None): """GUI 成功注册后的 CPA xai-*.json 导出 hook。""" log = log_callback or (lambda m: print(m, flush=True)) if not config.get("cpa_export_enabled", True): log("[cpa] export disabled, skip") return {"ok": False, "skipped": True, "reason": "disabled"} if not email or not password: log("[cpa] 缺少 email/password,跳过 CPA 导出") return {"ok": False, "error": "missing email/password"} try: import cpa_export except Exception as exc: log(f"[cpa] 导入 cpa_export 失败: {exc}") return {"ok": False, "error": f"import: {exc}"} cookies = [] try: cookies = cpa_export.export_cookies_from_page(page) if page is not None else [] except Exception as exc: log(f"[cpa] cookie 导出失败,继续用邮箱密码 mint: {exc}") cookies = [] if cookies: log(f"[cpa] 已导出 cookie {len(cookies)} 条供 OIDC mint 注入") cpa_cfg = dict(config) if _config_bool(config.get("cpa_gui_close_mint_browser", True), default=True): # GUI 下优先不残留额外 Chromium:CPA mint 成功后也立即 quit。 # CLI 仍可通过 cpa_mint_browser_reuse 保持流水线复用。 cpa_cfg["cpa_mint_browser_reuse"] = False # GUI 可多线程注册;CPA mint 使用独立有头浏览器,串行可避免多个 consent 窗口互相抢焦点。 with _cpa_gui_export_lock: try: result = cpa_export.export_cpa_xai_for_account( email, password, page=page, cookies=cookies, sso=sso, config=cpa_cfg, log_callback=log, ) except Exception as exc: log(f"[cpa] CPA 导出异常: {exc}") if config.get("cpa_mint_required", False): raise return {"ok": False, "error": str(exc)} if result.get("ok"): log(f"[cpa] CPA 格式已导出: {result.get('path')}") else: log(f"[cpa] CPA 导出失败: {result.get('error') or result}") return result class GrokRegisterGUI: def __init__(self, root): self.root = root self.root.title("Grok 注册机") self.root.geometry("980x860") self.root.minsize(900, 760) self.is_running = False self.batch_count = 0 self.success_count = 0 self.fail_count = 0 self.results = [] self.stop_requested = False self.ui_queue = queue.Queue() self.accounts_output_file = "" self.stats_lock = threading.Lock() self._tutorial_window = None self.setup_ui() self.root.after(200, self._maybe_show_tutorial_on_start) def setup_ui(self): load_config() main_frame = ttk.Frame(self.root, padding=10) main_frame.pack(fill=tk.BOTH, expand=True) config_frame = ttk.LabelFrame(main_frame, text="配置", padding=10) config_frame.pack(fill=tk.X, pady=5) ttk.Label(config_frame, text="邮箱服务商:").grid(row=0, column=0, sticky=tk.W) self.email_provider_var = tk.StringVar(value=config.get("email_provider", "duckmail")) self.email_provider_combo = ttk.Combobox(config_frame, textvariable=self.email_provider_var, values=["duckmail", "yyds", "cloudflare", "cloudmail", "hotmail", "outlookmail", "gmail"], width=12, state="readonly") self.email_provider_combo.grid(row=0, column=1, sticky=tk.W, padx=5) ttk.Label(config_frame, text="注册数量:").grid(row=0, column=2, sticky=tk.W, padx=10) self.count_var = tk.StringVar(value=str(config.get("register_count", 1))) self.count_spinbox = ttk.Spinbox(config_frame, from_=1, to=100, width=8, textvariable=self.count_var) self.count_spinbox.grid(row=0, column=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="并发线程:").grid(row=1, column=2, sticky=tk.W, padx=10) self.thread_var = tk.StringVar(value=str(config.get("register_threads", 1))) self.thread_spinbox = ttk.Spinbox(config_frame, from_=1, to=10, width=8, textvariable=self.thread_var) self.thread_spinbox.grid(row=1, column=3, sticky=tk.W, padx=5) self.nsfw_var = tk.BooleanVar(value=config.get("enable_nsfw", True)) self.nsfw_check = ttk.Checkbutton(config_frame, text="注册后开启 NSFW", variable=self.nsfw_var) self.nsfw_check.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=5) ttk.Label(config_frame, text="代理(可选):").grid(row=2, column=0, sticky=tk.W) self.proxy_var = tk.StringVar(value=config.get("proxy", "")) self.proxy_entry = ttk.Entry(config_frame, textvariable=self.proxy_var, width=30) self.proxy_entry.grid(row=2, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="DuckMail API Key:").grid(row=3, column=0, sticky=tk.W) self.api_key_var = tk.StringVar(value=config.get("duckmail_api_key", "")) self.api_key_entry = ttk.Entry(config_frame, textvariable=self.api_key_var, width=30) self.api_key_entry.grid(row=3, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="Cloudflare API Base:").grid(row=4, column=0, sticky=tk.W) self.cloudflare_api_base_var = tk.StringVar(value=config.get("cloudflare_api_base", "")) self.cloudflare_api_base_entry = ttk.Entry(config_frame, textvariable=self.cloudflare_api_base_var, width=30) self.cloudflare_api_base_entry.grid(row=4, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="Cloudflare API Key:").grid(row=5, column=0, sticky=tk.W) self.cloudflare_api_key_var = tk.StringVar(value=config.get("cloudflare_api_key", "")) self.cloudflare_api_key_entry = ttk.Entry(config_frame, textvariable=self.cloudflare_api_key_var, width=30) self.cloudflare_api_key_entry.grid(row=5, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="Cloudflare 鉴权模式:").grid(row=6, column=0, sticky=tk.W) self.cloudflare_auth_mode_var = tk.StringVar(value=config.get("cloudflare_auth_mode", "bearer")) self.cloudflare_auth_mode_combo = ttk.Combobox( config_frame, textvariable=self.cloudflare_auth_mode_var, values=["query-key", "bearer", "x-api-key", "none"], width=12, state="readonly", ) self.cloudflare_auth_mode_combo.grid(row=6, column=1, sticky=tk.W, padx=5) ttk.Label(config_frame, text="CF 路径(domains/accounts/token/messages):").grid(row=7, column=0, sticky=tk.W) self.cloudflare_paths_var = tk.StringVar( value=",".join( [ config.get("cloudflare_path_domains", "/domains"), config.get("cloudflare_path_accounts", "/accounts"), config.get("cloudflare_path_token", "/token"), config.get("cloudflare_path_messages", "/messages"), ] ) ) self.cloudflare_paths_entry = ttk.Entry(config_frame, textvariable=self.cloudflare_paths_var, width=30) self.cloudflare_paths_entry.grid(row=7, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="CloudMail URL:").grid(row=8, column=0, sticky=tk.W) self.cloudmail_url_var = tk.StringVar(value=str(config.get("cloudmail_url", ""))) self.cloudmail_url_entry = ttk.Entry(config_frame, textvariable=self.cloudmail_url_var, width=30) self.cloudmail_url_entry.grid(row=8, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="CloudMail 管理员邮箱:").grid(row=9, column=0, sticky=tk.W) self.cloudmail_admin_email_var = tk.StringVar(value=str(config.get("cloudmail_admin_email", ""))) self.cloudmail_admin_email_entry = ttk.Entry(config_frame, textvariable=self.cloudmail_admin_email_var, width=30) self.cloudmail_admin_email_entry.grid(row=9, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="CloudMail 管理员密码:").grid(row=10, column=0, sticky=tk.W) self.cloudmail_password_var = tk.StringVar(value=str(config.get("cloudmail_password", ""))) self.cloudmail_password_entry = ttk.Entry(config_frame, textvariable=self.cloudmail_password_var, width=30, show="*") self.cloudmail_password_entry.grid(row=10, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="grok2api 本地自动入池:").grid(row=11, column=0, sticky=tk.W) self.grok2api_local_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_local", True))) self.grok2api_local_auto_check = ttk.Checkbutton(config_frame, variable=self.grok2api_local_auto_var) self.grok2api_local_auto_check.grid(row=11, column=1, sticky=tk.W, padx=5) ttk.Label(config_frame, text="grok2api 本地 token.json:").grid(row=12, column=0, sticky=tk.W) self.grok2api_local_file_var = tk.StringVar(value=str(config.get("grok2api_local_token_file", ""))) self.grok2api_local_file_entry = ttk.Entry(config_frame, textvariable=self.grok2api_local_file_var, width=30) self.grok2api_local_file_entry.grid(row=12, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="grok2api 池名:").grid(row=13, column=0, sticky=tk.W) self.grok2api_pool_name_var = tk.StringVar(value=str(config.get("grok2api_pool_name", "ssoBasic"))) self.grok2api_pool_name_combo = ttk.Combobox( config_frame, textvariable=self.grok2api_pool_name_var, values=["ssoBasic", "ssoSuper"], width=12, state="readonly", ) self.grok2api_pool_name_combo.grid(row=13, column=1, sticky=tk.W, padx=5) ttk.Label(config_frame, text="grok2api 远端自动入池:").grid(row=14, column=0, sticky=tk.W) self.grok2api_remote_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_remote", False))) self.grok2api_remote_auto_check = ttk.Checkbutton(config_frame, variable=self.grok2api_remote_auto_var) self.grok2api_remote_auto_check.grid(row=14, column=1, sticky=tk.W, padx=5) ttk.Label(config_frame, text="grok2api 远端 Base:").grid(row=15, column=0, sticky=tk.W) self.grok2api_remote_base_var = tk.StringVar(value=str(config.get("grok2api_remote_base", ""))) self.grok2api_remote_base_entry = ttk.Entry(config_frame, textvariable=self.grok2api_remote_base_var, width=30) self.grok2api_remote_base_entry.grid(row=15, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="grok2api 远端 app_key:").grid(row=16, column=0, sticky=tk.W) self.grok2api_remote_key_var = tk.StringVar(value=str(config.get("grok2api_remote_app_key", ""))) self.grok2api_remote_key_entry = ttk.Entry(config_frame, textvariable=self.grok2api_remote_key_var, width=30) self.grok2api_remote_key_entry.grid(row=16, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="默认域名(defaultDomains):").grid(row=17, column=0, sticky=tk.W) self.default_domains_var = tk.StringVar(value=str(config.get("defaultDomains", ""))) self.default_domains_entry = ttk.Entry(config_frame, textvariable=self.default_domains_var, width=30) self.default_domains_entry.grid(row=17, column=1, columnspan=3, sticky=tk.W, padx=5) ttk.Label(config_frame, text="Hotmail账号文件:").grid(row=18, column=0, sticky=tk.W) self.hotmail_accounts_file_var = tk.StringVar(value=str(config.get("hotmail_accounts_file", "mail_credentials.txt"))) self.hotmail_accounts_file_entry = ttk.Entry(config_frame, textvariable=self.hotmail_accounts_file_var, width=30) self.hotmail_accounts_file_entry.grid(row=18, column=1, columnspan=3, sticky=tk.W, padx=5) btn_frame = ttk.Frame(main_frame) btn_frame.pack(fill=tk.X, pady=10) self.start_btn = ttk.Button(btn_frame, text="开始注册", command=self.start_registration) self.start_btn.pack(side=tk.LEFT, padx=5) self.stop_btn = ttk.Button(btn_frame, text="停止", command=self.stop_registration, state=tk.DISABLED) self.stop_btn.pack(side=tk.LEFT, padx=5) self.clear_btn = ttk.Button(btn_frame, text="清空日志", command=self.clear_log) self.clear_btn.pack(side=tk.LEFT, padx=5) self.help_btn = ttk.Button(btn_frame, text="教程", command=self.show_tutorial) self.help_btn.pack(side=tk.LEFT, padx=5) status_frame = ttk.Frame(main_frame) status_frame.pack(fill=tk.X, pady=5) self.status_var = tk.StringVar(value="就绪") ttk.Label(status_frame, text="状态: ").pack(side=tk.LEFT) self.status_label = ttk.Label(status_frame, textvariable=self.status_var, foreground="green") self.status_label.pack(side=tk.LEFT) self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0") ttk.Label(status_frame, textvariable=self.stats_var).pack(side=tk.RIGHT) log_frame = ttk.LabelFrame(main_frame, text="日志", padding=5) log_frame.pack(fill=tk.BOTH, expand=True, pady=5) self.log_text = scrolledtext.ScrolledText(log_frame, height=15, width=60) self.log_text.pack(fill=tk.BOTH, expand=True) def log(self, message): timestamp = datetime.datetime.now().strftime("%H:%M:%S") # 仅当用户当前就在底部时自动跟随,避免手动上滑后被强制拉回底部 yview = self.log_text.yview() at_bottom = bool(yview) and yview[1] >= 0.999 self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") if at_bottom: self.log_text.see(tk.END) def clear_log(self): self.log_text.delete(1.0, tk.END) def update_stats(self): self.stats_var.set(f"成功: {self.success_count} | 失败: {self.fail_count}") def _set_running_ui(self, running): self.is_running = running self.start_btn.config(state=tk.DISABLED if running else tk.NORMAL) self.stop_btn.config(state=tk.NORMAL if running else tk.DISABLED) self.status_var.set("运行中..." if running else "就绪") self.status_label.config(foreground="blue" if running else "green") def _maybe_show_tutorial_on_start(self): if bool(config.get("show_tutorial_on_start", True)): self.show_tutorial() def _tutorial_text(self): return """欢迎使用 Grok 注册机。建议按下面顺序填写(从最关键到可选): 【第一步:先确定邮箱后端信息从哪里来】 如果你使用 cloudflare 模式(你当前主要是这套),先去你的临时邮箱服务配置接口查信息: - 常见接口: /open_api/settings、/api/settings、/health_check - 重点字段: - api_base(对应本工具的 Cloudflare API Base) - domains / defaultDomains(可用域名) - needAuth(是否需要鉴权) - admin_password 或 api_key(需要鉴权时使用) - provider.type(应为 cloudflare_temp_email) 【第二步:先填最小可运行配置】 1) 邮箱服务商 - duckmail: 需要 DuckMail API Key - yyds: 需要 YYDS API Key 或 JWT - cloudflare: 需要 Cloudflare API Base(cloudflare_temp_email 临时邮箱) - cloudmail: 需要 CloudMail URL + 密码 + defaultDomains(maillab/cloud-mail 完整邮箱) - hotmail/outlookmail: 需要 Hotmail账号文件,格式为 邮箱----密码----ClientID----Token 2) Cloudflare API Base(cloudflare 模式必填) - 示例: https://xxxx.pages.dev - 填写规则: 与 settings 接口中的 api_base 保持一致 3) 默认域名(defaultDomains) - 填写你要优先使用的域名 - 支持单域名或逗号分隔多域名轮换 - 示例: a.com,b.com 4) CF 路径(domains/accounts/token/messages) - 必须与后端真实路由一致 - 常见新路径: - /api/domains,/api/new_address,/api/token,/api/mails - 常见旧路径: - /domains,/accounts,/token,/messages 5) Cloudflare API Key / 鉴权模式 - needAuth=false: 通常鉴权模式选 none,key 可留空 - needAuth=true: 按后端要求填 key,并选择 bearer/x-api-key/query-key 6) CloudMail 模式配置(maillab/cloud-mail 部署) - CloudMail URL: 你的 Worker 地址,如 https://mail.xxx.workers.dev - CloudMail 管理员邮箱: 管理员账号,如 admin@yourdomain.com - CloudMail 管理员密码: 管理员密码(用于获取公开 API token 查询邮件) - defaultDomains: 必须填写可用域名,如 yourdomain.com - 前提: CloudMail 管理面板需关闭注册验证码(Turnstile),或确保注册接口可用 - 邮件获取: 通过 /api/public/emailList 公开接口查询,自动刷新 token 7) Hotmail/Outlook 模式配置 - Hotmail账号文件默认: mail_credentials.txt - 每行格式: your@hotmail.com----mailPassword----client-id----refresh-token - 默认先用原邮箱,后续使用随机 plus alias(如 name+k8s2p9qa@domain) - 成功、失败、当前运行占用的 alias 都会去重,并通过 outlook.office365.com XOAUTH2 IMAP 收验证码 【第三步:并发与稳定性】 6) 注册数量 - 本次要注册的总账号数 7) 并发线程 - 建议先 3-6 稳定后再升到 10 8) 代理(可选) - 不填=直连 - 示例: http://127.0.0.1:7890 - 代理不稳会影响验证码和注册稳定性 9) 注册后开启 NSFW - 勾选后成功账号会自动调用接口开启对应设置 【第四步:grok2api 入池(可选)】 10) grok2api 本地自动入池 - 开启后把成功 sso 自动写入本地池 - 本地 token.json 填 grok2api 的 token.json 路径 11) grok2api 池名 - ssoBasic 或 ssoSuper 12) grok2api 远端自动入池 - 开启后调用远端管理接口自动加 token - 远端 Base 示例: https://xxx/admin/api - app_key 按远端服务配置填写 【最后:快速自检】 1) 先设置: 注册数量=1,并发线程=1 2) 点开始后看日志是否出现: - 已创建邮箱: xxx@你的域名 - Cloudflare/CloudMail 本轮邮件数量: ... - 从邮件中提取到验证码: ... 3) 若第一步就失败: - cloudflare 模式: 检查 API Base / CF 路径 / 鉴权模式 - cloudmail 模式: 检查 URL / 密码 / defaultDomains / 注册接口是否可用 提示: - 点“开始注册”会自动保存当前配置到 config.json。 - 如果关闭了启动教程,可随时点主界面的“教程”按钮重新打开。""" def show_tutorial(self): if self._tutorial_window is not None and self._tutorial_window.winfo_exists(): self._tutorial_window.lift() self._tutorial_window.focus_force() return win = tk.Toplevel(self.root) self._tutorial_window = win win.title("使用教程") win.geometry("760x620") win.minsize(680, 520) win.transient(self.root) frame = ttk.Frame(win, padding=10) frame.pack(fill=tk.BOTH, expand=True) txt = scrolledtext.ScrolledText(frame, wrap=tk.WORD, height=26) txt.pack(fill=tk.BOTH, expand=True) txt.insert("1.0", self._tutorial_text()) txt.config(state=tk.DISABLED) footer = ttk.Frame(frame) footer.pack(fill=tk.X, pady=(8, 0)) dont_show_var = tk.BooleanVar(value=not bool(config.get("show_tutorial_on_start", True))) chk = ttk.Checkbutton( footer, text="以后不再自动显示本教程", variable=dont_show_var, ) chk.pack(side=tk.LEFT) def on_close(): config["show_tutorial_on_start"] = not bool(dont_show_var.get()) save_config() try: win.destroy() except Exception: pass close_btn = ttk.Button(footer, text="关闭", command=on_close) close_btn.pack(side=tk.RIGHT, padx=5) win.protocol("WM_DELETE_WINDOW", on_close) def should_stop(self): return self.stop_requested or not self.is_running def start_registration(self): if self.is_running: self.log("[!] 当前已有任务在运行") return config["email_provider"] = self.email_provider_var.get().strip() or "duckmail" config["proxy"] = self.proxy_var.get().strip() config["duckmail_api_key"] = self.api_key_var.get().strip() config["cloudflare_api_base"] = self.cloudflare_api_base_var.get().strip() config["cloudflare_api_key"] = self.cloudflare_api_key_var.get().strip() config["cloudflare_auth_mode"] = self.cloudflare_auth_mode_var.get().strip() or "bearer" config["cloudmail_url"] = self.cloudmail_url_var.get().strip() config["cloudmail_admin_email"] = self.cloudmail_admin_email_var.get().strip() config["cloudmail_password"] = self.cloudmail_password_var.get().strip() config["grok2api_auto_add_local"] = bool(self.grok2api_local_auto_var.get()) config["grok2api_local_token_file"] = self.grok2api_local_file_var.get().strip() config["grok2api_pool_name"] = self.grok2api_pool_name_var.get().strip() or "ssoBasic" config["grok2api_auto_add_remote"] = bool(self.grok2api_remote_auto_var.get()) config["grok2api_remote_base"] = self.grok2api_remote_base_var.get().strip() config["grok2api_remote_app_key"] = self.grok2api_remote_key_var.get().strip() config["defaultDomains"] = self.default_domains_var.get().strip() config["hotmail_accounts_file"] = self.hotmail_accounts_file_var.get().strip() or "mail_credentials.txt" try: config["register_threads"] = max(1, min(10, int(self.thread_var.get()))) except Exception: config["register_threads"] = 1 raw_paths = [x.strip() for x in self.cloudflare_paths_var.get().split(",") if x.strip()] if len(raw_paths) >= 4: config["cloudflare_path_domains"] = raw_paths[0] if raw_paths[0].startswith("/") else ("/" + raw_paths[0]) config["cloudflare_path_accounts"] = raw_paths[1] if raw_paths[1].startswith("/") else ("/" + raw_paths[1]) config["cloudflare_path_token"] = raw_paths[2] if raw_paths[2].startswith("/") else ("/" + raw_paths[2]) config["cloudflare_path_messages"] = raw_paths[3] if raw_paths[3].startswith("/") else ("/" + raw_paths[3]) save_config() if config["email_provider"] == "cloudflare" and not config["cloudflare_api_base"]: self.log("[!] Cloudflare 模式需要先填写 Cloudflare API Base") return if config["email_provider"] == "cloudmail": if not config.get("cloudmail_url"): self.log("[!] CloudMail 模式需要先填写 CloudMail URL") return if not config.get("cloudmail_admin_email"): self.log("[!] CloudMail 模式需要先填写 CloudMail 管理员邮箱") return if not config.get("cloudmail_password"): self.log("[!] CloudMail 模式需要先填写 CloudMail 管理员密码") return if config["email_provider"] in ("hotmail", "outlook", "outlookmail", "microsoft"): hotmail_path = get_hotmail_accounts_file() if not os.path.exists(hotmail_path): self.log(f"[!] Hotmail/Outlook 模式账号文件不存在: {hotmail_path}") return try: count = int(self.count_var.get()) except Exception: self.log("[!] 注册数量无效") return self.stop_requested = False self.success_count = 0 self.fail_count = 0 self.results = [] now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self.accounts_output_file = os.path.join( os.path.dirname(__file__), f"accounts_{now}.txt" ) self.update_stats() self._set_running_ui(True) worker_count = max(1, min(config.get("register_threads", 1), count)) self.log(f"[*] 配置已保存,开始执行。目标数量: {count},并发线程: {worker_count}") self.log(f"[*] 成功账号将实时保存到: {self.accounts_output_file}") threading.Thread( target=self.run_registration, args=(count, worker_count), daemon=True, ).start() def stop_registration(self): self.stop_requested = True self.log("[!] 用户停止注册") def _run_single_registration(self, idx, total, logf): email = "" dev_token = "" code = "" mail_ok = False max_mail_retry = 3 for mail_try in range(1, max_mail_retry + 1): logf(f"[*] 1. 打开注册页 (尝试 {mail_try}/{max_mail_retry})") open_signup_page(log_callback=logf, cancel_callback=self.should_stop) logf("[*] 2. 创建邮箱并提交") email, dev_token = fill_email_and_submit(log_callback=logf, cancel_callback=self.should_stop) logf(f"[*] 邮箱: {email}") if get_email_provider() not in ("hotmail", "outlook", "outlookmail", "microsoft"): try: with open(os.path.join(os.path.dirname(__file__), "created_mailboxes.txt"), "a", encoding="utf-8") as f: f.write(f"{email}\t{dev_token}\n") except Exception: pass logf("[*] 3. 拉取验证码") try: code = fill_code_and_submit(email, dev_token, log_callback=logf, cancel_callback=self.should_stop) mail_ok = True break except Exception as mail_exc: msg = str(mail_exc) if email: try: mark_error(email, reason=msg[:120]) except Exception: pass if ("未收到验证码" in msg or "验证码" in msg) and mail_try < max_mail_retry: logf(f"[!] 本邮箱未取到验证码,自动更换新邮箱重试: {msg}") restart_browser(log_callback=logf) sleep_with_cancel(1, self.should_stop) continue raise if not mail_ok: raise Exception("验证码阶段失败,已达到最大重试次数") logf(f"[*] 验证码: {code}") try: logf("[*] 4. 填写资料") profile = fill_profile_and_submit(log_callback=logf, cancel_callback=self.should_stop) logf(f"[*] 资料已填: {profile.get('given_name')} {profile.get('family_name')}") logf("[*] 5. 等待 sso cookie") sso = wait_for_sso_cookie(log_callback=logf, cancel_callback=self.should_stop) except Exception as flow_exc: if email: try: mark_error(email, reason=str(flow_exc)[:120]) except Exception: pass raise password = profile.get("password", "") or "" result_record = {"email": email, "sso": sso, "profile": profile} with self.stats_lock: self.results.append(result_record) self.success_count += 1 line = f"{email}----{password}----{sso}\n" try: with open(self.accounts_output_file, "a", encoding="utf-8") as f: f.write(line) except Exception as file_exc: logf(f"[Debug] 保存账号文件失败: {file_exc}") try: mark_used(email, password) except Exception: pass logf(f"[+] 注册成功: {email}") add_token_to_grok2api_pools(sso, email=email, log_callback=logf) try: page = _get_page() except Exception: page = None logf("[cpa] 开始自动导出 CPA xai 认证文件") cpa_result = export_cpa_after_success( email, password, sso, page=page, log_callback=logf, ) result_record["cpa"] = cpa_result def _worker_loop(self, worker_id, total, task_queue): prefix = f"[T{worker_id}]" logf = lambda m: self.log(f"{prefix} {m}") try: start_browser(log_callback=logf) logf("[*] 浏览器已启动") while not self.should_stop(): try: idx = task_queue.get_nowait() except queue.Empty: break logf(f"--- 开始第 {idx}/{total} 个账号 ---") try: self._run_single_registration(idx, total, logf) except RegistrationCancelled: logf("[!] 注册被用户停止") break except Exception as exc: with self.stats_lock: self.fail_count += 1 logf(f"[-] 注册失败: {exc}") finally: self.update_stats() if self.should_stop(): break restart_browser(log_callback=logf) sleep_with_cancel(1, self.should_stop) except Exception as exc: logf(f"[!] 线程异常: {exc}") finally: stop_browser() def run_registration(self, count, worker_count): task_queue = queue.Queue() for i in range(1, count + 1): task_queue.put(i) workers = [] try: start_interval = float(config.get("thread_start_interval", 0.8)) except Exception: start_interval = 0.8 if start_interval < 0: start_interval = 0.0 for wid in range(1, worker_count + 1): t = threading.Thread(target=self._worker_loop, args=(wid, count, task_queue), daemon=True) workers.append(t) t.start() if wid < worker_count and start_interval > 0: sleep_with_cancel(start_interval, self.should_stop) for t in workers: t.join() self._set_running_ui(False) self.log("[*] 任务结束") def main(): root = tk.Tk() app = GrokRegisterGUI(root) root.mainloop() if __name__ == "__main__": main()