#!/usr/bin/env python # -*- coding: utf-8 -*- """ Grok 注册机 - TTK GUI 版本 整合 DrissionPage_example.py, openai_register.py, batch_open_nsfw.py """ try: import tkinter as tk from tkinter import ttk, messagebox, scrolledtext TK_AVAILABLE = True TK_IMPORT_ERROR = None except ImportError as exc: tk = None ttk = None messagebox = None scrolledtext = None TK_AVAILABLE = False TK_IMPORT_ERROR = exc import threading import datetime import time import os import sys import gc import queue import secrets import struct import random import re import string import json import base64 import select import socket import socketserver import ssl import urllib.parse import tempfile import traceback os.environ.setdefault("TK_SILENCE_DEPRECATION", "1") 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") MEMORY_CLEANUP_INTERVAL = 5 UI_BG = "#242424" UI_PANEL_BG = "#2b2b2b" UI_FG = "#f2f2f2" UI_MUTED_FG = "#b8b8b8" UI_ENTRY_BG = "#333333" UI_BUTTON_BG = "#3a3a3a" UI_ACTIVE_BG = "#4a6078" DEFAULT_CONFIG = { "duckmail_api_key": "", "cloudflare_api_base": "", "cloudflare_api_key": "", "cloudflare_auth_mode": "none", "cloudflare_path_domains": "/api/domains", "cloudflare_path_accounts": "/api/new_address", "cloudflare_path_token": "/api/token", "cloudflare_path_messages": "/api/mails", "cloudmail_api_base": "", "cloudmail_public_token": "", "cloudmail_domains": "", "cloudmail_path_messages": "/api/public/emailList", "proxy": "", "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": False, "grok2api_local_token_file": "", "grok2api_pool_name": "ssoBasic", "grok2api_auto_add_remote": False, "grok2api_remote_base": "", "grok2api_remote_app_key": "", "api_reverse_tools": "", "cpa_export_enabled": False, "cpa_auth_dir": "./cpa_auths", "cpa_copy_to_hotload": False, "cpa_hotload_dir": "", "cpa_base_url": "https://cli-chat-proxy.grok.com/v1", "cpa_proxy": "", "cpa_headless": False, "cpa_force_standalone": True, "cpa_mint_timeout_sec": 300, "cpa_mint_cookie_inject": True, "cpa_oidc_request_timeout_sec": 15, "cpa_oidc_poll_timeout_sec": 15, "grok2api_allow_legacy_full_save": False, "email_provider": "duckmail", "yyds_api_key": "", "yyds_jwt": "", "defaultDomains": "", } config = DEFAULT_CONFIG.copy() _cf_domain_index = 0 _cloudmail_domain_index = 0 class RegistrationCancelled(Exception): pass class AccountRetryNeeded(Exception): pass class ConfigError(RuntimeError): pass class RemoteTokenCompatibilityError(RuntimeError): pass class RemoteTokenRequestError(RuntimeError): pass def log_exception(context, exc, log_callback=None): message = f"{context}: {exc.__class__.__name__}: {exc}" if log_callback: log_callback(f"[!] {message}") else: print(f"[!] {message}", file=sys.stderr) return message def _require_bool(cfg, key): value = cfg.get(key) if type(value) is not bool: raise ConfigError(f"配置项 {key} 必须是布尔值 true/false") return value def _require_int(cfg, key, minimum, maximum): value = cfg.get(key) if type(value) is not int: raise ConfigError(f"配置项 {key} 必须是整数") if not minimum <= value <= maximum: raise ConfigError(f"配置项 {key} 必须在 {minimum} 到 {maximum} 之间") return value def _require_string(cfg, key, path=False): value = cfg.get(key) if not isinstance(value, str): raise ConfigError(f"配置项 {key} 必须是字符串") value = value.strip() if key not in ("user_agent",) else value if "\x00" in value: raise ConfigError(f"配置项 {key} 包含非法空字符") if path and value: os.path.expanduser(value) return value def validate_config_structure(raw): if not isinstance(raw, dict): raise ConfigError("config root must be a JSON object") cfg = {**DEFAULT_CONFIG, **raw} bool_keys = ( "enable_nsfw", "grok2api_auto_add_local", "grok2api_auto_add_remote", "grok2api_allow_legacy_full_save", "cpa_export_enabled", "cpa_copy_to_hotload", "cpa_headless", "cpa_force_standalone", "cpa_mint_cookie_inject", ) for key in bool_keys: cfg[key] = _require_bool(cfg, key) cfg["register_count"] = _require_int(cfg, "register_count", 1, 2500) cfg["cpa_mint_timeout_sec"] = _require_int(cfg, "cpa_mint_timeout_sec", 30, 1800) cfg["cpa_oidc_request_timeout_sec"] = _require_int(cfg, "cpa_oidc_request_timeout_sec", 3, 120) cfg["cpa_oidc_poll_timeout_sec"] = _require_int(cfg, "cpa_oidc_poll_timeout_sec", 3, 120) string_keys = tuple(key for key, value in DEFAULT_CONFIG.items() if isinstance(value, str)) path_keys = {"grok2api_local_token_file", "api_reverse_tools", "cpa_auth_dir", "cpa_hotload_dir"} for key in string_keys: cfg[key] = _require_string(cfg, key, path=key in path_keys) enums = { "email_provider": {"duckmail", "yyds", "cloudflare", "cloudmail"}, "cloudflare_auth_mode": {"query-key", "bearer", "x-api-key", "x-admin-auth", "none"}, "grok2api_pool_name": {"ssoBasic", "ssoSuper"}, } for key, allowed in enums.items(): value = cfg.get(key, DEFAULT_CONFIG.get(key, "")) if value not in allowed: raise ConfigError(f"配置项 {key} 的值无效: {value!r}; 允许值: {sorted(allowed)}") cfg[key] = value api_path_keys = { "cloudflare_path_domains", "cloudflare_path_accounts", "cloudflare_path_token", "cloudflare_path_messages", "cloudmail_path_messages", } for key in api_path_keys: value = cfg[key] if value and not value.startswith("/"): value = "/" + value cfg[key] = value url_keys = { "cloudflare_api_base", "cloudmail_api_base", "grok2api_remote_base", "cpa_base_url", } for key in url_keys: value = cfg[key] if not value: continue parsed = urllib.parse.urlsplit(value) if parsed.scheme not in ("http", "https") or not parsed.netloc: raise ConfigError(f"配置项 {key} 必须是有效的 http/https URL") for key in path_keys: value = cfg[key] if value.startswith("~"): cfg[key] = os.path.expanduser(value) return cfg def validate_run_requirements(cfg): cfg = validate_config_structure(cfg) provider = cfg["email_provider"] if provider == "cloudflare" and not cfg["cloudflare_api_base"]: raise ConfigError("Cloudflare 模式需要配置 cloudflare_api_base") if provider == "cloudmail": missing = [ key for key in ("cloudmail_api_base", "cloudmail_public_token", "cloudmail_domains") if not cfg[key] ] if missing: raise ConfigError("Cloud Mail 模式缺少必需配置: " + ", ".join(missing)) if provider == "yyds" and not (cfg["yyds_api_key"] or cfg["yyds_jwt"]): raise ConfigError("YYDS 模式需要至少配置 yyds_api_key 或 yyds_jwt") if cfg["grok2api_auto_add_remote"]: missing = [ key for key in ("grok2api_remote_base", "grok2api_remote_app_key") if not cfg[key] ] if missing: raise ConfigError("远端 token 入池缺少必需配置: " + ", ".join(missing)) if cfg["cpa_copy_to_hotload"] and not cfg["cpa_hotload_dir"]: raise ConfigError("启用 CPA 热加载复制时必须配置 cpa_hotload_dir") return cfg def validate_config(raw): """Backward-compatible full validation used before a run or save.""" return validate_run_requirements(raw) def load_config(): global config if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: loaded = json.load(f) config = validate_config_structure(loaded) except ConfigError: raise except Exception as exc: raise ConfigError(f"配置文件解析失败: {CONFIG_FILE}: {exc}") from exc else: config = validate_config_structure(DEFAULT_CONFIG.copy()) return config def save_config(): global config config = validate_config_structure(config) config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) os.makedirs(config_dir, exist_ok=True) fd = None temp_path = None try: fd, temp_path = tempfile.mkstemp(prefix=".config-", suffix=".json.tmp", dir=config_dir) with os.fdopen(fd, "w", encoding="utf-8") as f: fd = None json.dump(config, f, indent=4, ensure_ascii=False) f.write("\n") f.flush() os.fsync(f.fileno()) try: os.chmod(temp_path, 0o600) except Exception: pass os.replace(temp_path, CONFIG_FILE) temp_path = None try: os.chmod(CONFIG_FILE, 0o600) except Exception: pass except Exception as exc: raise ConfigError(f"保存配置失败: {exc}") from exc finally: if fd is not None: try: os.close(fd) except Exception: pass if temp_path and os.path.exists(temp_path): try: os.unlink(temp_path) except Exception: pass 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() EXTENSION_PATH = os.path.abspath( os.path.join(os.path.dirname(__file__), "turnstilePatch") ) DUCKMAIL_API_BASE = "https://api.duckmail.sbs" def get_configured_proxy(): return str(config.get("proxy", "") or "").strip() def get_proxies(): proxy = get_configured_proxy() if proxy: return {"http": proxy, "https": proxy} return {} def _parse_proxy_url(proxy): raw = str(proxy or "").strip() if not raw: return None if "://" not in raw: raw = "http://" + raw try: return urllib.parse.urlsplit(raw) except Exception: return None def _safe_proxy_port(parsed): try: return parsed.port except Exception: return None def _proxy_has_auth(proxy): parsed = _parse_proxy_url(proxy) return bool(parsed and parsed.hostname and (parsed.username is not None or parsed.password is not None)) def _strip_proxy_auth(proxy): raw = str(proxy or "").strip() parsed = _parse_proxy_url(raw) if not parsed or not parsed.hostname: return raw host = parsed.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" port = _safe_proxy_port(parsed) netloc = f"{host}:{port}" if port else host stripped = urllib.parse.urlunsplit((parsed.scheme or "http", netloc, parsed.path, parsed.query, parsed.fragment)) if "://" not in raw: return stripped.split("://", 1)[1] return stripped def _proxy_endpoint_terms(proxy=None): parsed = _parse_proxy_url(proxy or get_configured_proxy()) if not parsed or not parsed.hostname: return [] terms = [parsed.hostname] port = _safe_proxy_port(parsed) if port: terms.append(f"{parsed.hostname}:{port}") terms.append(f"port {port}") return [x.lower() for x in terms if x] def is_proxy_connection_error(exc): if not get_configured_proxy(): return False err = str(exc or "").lower() if not err: return False if any(x in err for x in ("proxy", "tunnel", "socks")): return True connect_markers = ( "could not connect", "failed to connect", "connection refused", "connection reset", "connect error", "timed out", "timeout", ) if any(x in err for x in connect_markers): terms = _proxy_endpoint_terms() if not terms or any(t in err for t in terms): return True return False def page_has_proxy_error(page_obj): try: url = str(getattr(page_obj, "url", "") or "") title = str(page_obj.run_js("return document.title || ''") or "") body = str(page_obj.run_js("return document.body ? document.body.innerText.slice(0, 2000) : ''") or "") except Exception: return False text = f"{url}\n{title}\n{body}".lower() return any( marker in text for marker in ( "err_proxy", "proxy connection failed", "proxy server", "proxy authentication", "tunnel connection failed", "无法连接到代理服务器", "代理服务器", ) ) class _ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True def _proxy_recv_until_headers(sock, timeout=20, limit=65536): sock.settimeout(timeout) data = b"" while b"\r\n\r\n" not in data and len(data) < limit: chunk = sock.recv(4096) if not chunk: break data += chunk return data def _proxy_relay(left, right, timeout=60): left.settimeout(timeout) right.settimeout(timeout) sockets = [left, right] while True: readable, _, _ = select.select(sockets, [], [], timeout) if not readable: return for sock in readable: data = sock.recv(65536) if not data: return peer = right if sock is left else left peer.sendall(data) class _LocalAuthProxyBridgeHandler(socketserver.BaseRequestHandler): def handle(self): bridge = self.server.bridge upstream = None try: initial = _proxy_recv_until_headers(self.request, timeout=bridge.timeout) if not initial: return first_line = initial.split(b"\r\n", 1)[0].decode("latin1", "ignore") if first_line.upper().startswith("CONNECT "): target = first_line.split()[1] upstream = bridge.open_upstream() req = [f"CONNECT {target} HTTP/1.1", f"Host: {target}"] if bridge.auth_header: req.append(f"Proxy-Authorization: Basic {bridge.auth_header}") upstream.sendall(("\r\n".join(req) + "\r\n\r\n").encode("latin1")) response = _proxy_recv_until_headers(upstream, timeout=bridge.timeout) if response: self.request.sendall(response) status = response.split(b"\r\n", 1)[0] if b" 200 " not in status: return _proxy_relay(self.request, upstream, timeout=bridge.relay_timeout) else: upstream = bridge.open_upstream() upstream.sendall(bridge.inject_proxy_auth(initial)) _proxy_relay(self.request, upstream, timeout=bridge.relay_timeout) except Exception: return finally: if upstream is not None: try: upstream.close() except Exception: pass class LocalAuthProxyBridge: def __init__(self, proxy_url): parsed = _parse_proxy_url(proxy_url) if not parsed or not parsed.hostname: raise ValueError("认证代理地址格式无效") if (parsed.scheme or "http").lower() not in ("http", "https"): raise ValueError("Chromium 本地认证代理桥仅支持 http/https 上游代理") self.upstream_scheme = (parsed.scheme or "http").lower() self.upstream_host = parsed.hostname self.upstream_port = _safe_proxy_port(parsed) or (443 if self.upstream_scheme == "https" else 80) username = urllib.parse.unquote(parsed.username or "") password = urllib.parse.unquote(parsed.password or "") raw_auth = f"{username}:{password}".encode("utf-8") self.auth_header = base64.b64encode(raw_auth).decode("ascii") if (username or password) else "" self.timeout = 20 self.relay_timeout = 90 self.server = None self.thread = None self.local_proxy = "" def open_upstream(self): sock = socket.create_connection((self.upstream_host, self.upstream_port), timeout=self.timeout) if self.upstream_scheme == "https": context = ssl.create_default_context() sock = context.wrap_socket(sock, server_hostname=self.upstream_host) sock.settimeout(self.timeout) return sock def inject_proxy_auth(self, data): if not self.auth_header or b"\r\n\r\n" not in data: return data if b"\r\nproxy-authorization:" in data.lower(): return data head, body = data.split(b"\r\n\r\n", 1) auth_line = f"Proxy-Authorization: Basic {self.auth_header}".encode("latin1") return head + b"\r\n" + auth_line + b"\r\n\r\n" + body def start(self): self.server = _ReusableThreadingTCPServer(("127.0.0.1", 0), _LocalAuthProxyBridgeHandler) self.server.bridge = self port = self.server.server_address[1] self.local_proxy = f"http://127.0.0.1:{port}" self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() return self.local_proxy def stop(self): if self.server is not None: try: self.server.shutdown() self.server.server_close() except Exception: pass self.server = None self.thread = None self.local_proxy = "" def stop_browser_proxy_bridge(): global browser_proxy_bridge if browser_proxy_bridge is not None: try: browser_proxy_bridge.stop() except Exception: pass browser_proxy_bridge = None def prepare_browser_proxy(use_proxy=True, log_callback=None): proxy = get_configured_proxy() if not use_proxy or not proxy: return "", None if _proxy_has_auth(proxy): parsed = _parse_proxy_url(proxy) scheme = (parsed.scheme or "http").lower() if parsed else "" if scheme in ("http", "https"): bridge = LocalAuthProxyBridge(proxy) browser_proxy = bridge.start() if log_callback: log_callback(f"[*] 已为 Chromium 启动本地认证代理桥: {browser_proxy}") return browser_proxy, bridge stripped = _strip_proxy_auth(proxy) if log_callback: log_callback("[!] Chromium 暂不直接支持该认证代理协议,已使用去认证代理地址,失败将回退直连") return stripped, None return proxy, None def get_duckmail_api_key(): return config.get("duckmail_api_key", "") def get_cloudflare_api_base(): return str(config.get("cloudflare_api_base", "") or "").rstrip("/") def get_cloudflare_api_key(): return config.get("cloudflare_api_key", "") def get_cloudflare_auth_mode(): return str(config.get("cloudflare_auth_mode", "none") or "none").lower() def get_cloudflare_path(key, default_path): raw = str(config.get(key, default_path) or default_path).strip() if not raw.startswith("/"): raw = "/" + raw return raw def cloudflare_build_headers(content_type=False): headers = {"Content-Type": "application/json"} if content_type else {} key = get_cloudflare_api_key() mode = get_cloudflare_auth_mode() if key: if mode == "x-api-key": headers["X-API-Key"] = key elif mode == "x-admin-auth": headers["x-admin-auth"] = key elif mode != "none": headers["Authorization"] = f"Bearer {key}" return headers def cloudflare_apply_auth_params(params=None): merged = dict(params or {}) key = get_cloudflare_api_key() mode = get_cloudflare_auth_mode() if key and mode == "query-key": merged["key"] = key return merged def cloudflare_next_default_domain(): """按配置轮换选择 Cloudflare 临时邮箱域名。""" global _cf_domain_index domains = [x.strip() for x in str(config.get("defaultDomains", "") or "").split(",") if x.strip()] if not domains: return "" domain = domains[_cf_domain_index % len(domains)] _cf_domain_index += 1 return domain def cloudflare_is_admin_create_path(path): """判断当前创建邮箱路径是否为 cloudflare_temp_email 管理员创建接口。""" return str(path or "").rstrip("/").lower() == "/admin/new_address" def _pick_list_payload(data): if isinstance(data, list): return data if isinstance(data, dict): if isinstance(data.get("results"), list): return data.get("results") if isinstance(data.get("hydra:member"), list): return data.get("hydra:member") if isinstance(data.get("data"), list): return data.get("data") if isinstance(data.get("messages"), list): return data.get("messages") if isinstance(data.get("data"), dict): nested = data.get("data") if isinstance(nested.get("messages"), list): return nested.get("messages") return [] def cloudflare_create_temp_address(api_base): """适配 cloudflare_temp_email 新建地址接口并兼容 admin 创建模式。""" path = get_cloudflare_path("cloudflare_path_accounts", "/api/new_address") url = f"{api_base}{path}" domain = cloudflare_next_default_domain() is_admin_create = cloudflare_is_admin_create_path(path) if is_admin_create: payload = {"name": generate_username(10), "enablePrefix": True} if domain: payload["domain"] = domain headers = cloudflare_build_headers(content_type=True) else: payload = {} if domain: payload["domain"] = domain headers = {"Content-Type": "application/json"} resp = http_post(url, json=payload, headers=headers) resp.raise_for_status() try: data = resp.json() except Exception: raise Exception(f"Cloudflare {path} 返回非JSON: {resp.text[:300]}") address = data.get("address") jwt = data.get("jwt") if not address or not jwt: raise Exception(f"Cloudflare {path} 缺少 address/jwt: {data}") return address, jwt def get_cloudmail_api_base(): return str(config.get("cloudmail_api_base", "") or "").strip().rstrip("/") def get_cloudmail_public_token(): return str(config.get("cloudmail_public_token", "") or "").strip() def get_cloudmail_path(): raw = str( config.get("cloudmail_path_messages", "/api/public/emailList") or "/api/public/emailList" ).strip() return raw if raw.startswith("/") else "/" + raw def cloudmail_next_domain(): """按配置轮换选择 Cloud Mail 无人收件域名。""" global _cloudmail_domain_index domains = [ item.strip().lstrip("@") for item in str(config.get("cloudmail_domains", "") or "").split(",") if item.strip().lstrip("@") ] if not domains: return "" domain = domains[_cloudmail_domain_index % len(domains)] _cloudmail_domain_index += 1 return domain def cloudmail_get_email_and_token(): """生成无需预创建账号的 Cloud Mail 收件地址。""" if not get_cloudmail_api_base(): raise Exception("Cloud Mail API Base 未配置") if not get_cloudmail_public_token(): raise Exception("Cloud Mail Public Token 未配置") domain = cloudmail_next_domain() if not domain: raise Exception("Cloud Mail 收件域名未配置") address = f"{generate_username(12)}@{domain}" # 仅返回非敏感占位凭证;公共 Token 始终只从 config.json 读取。 return address, f"cloudmail:{address}" def cloudmail_get_messages(address): api_base = get_cloudmail_api_base() public_token = get_cloudmail_public_token() if not api_base: raise Exception("Cloud Mail API Base 未配置") if not public_token: raise Exception("Cloud Mail Public Token 未配置") payload = { "toEmail": address, "type": 0, "isDel": 0, "timeSort": "desc", "num": 1, "size": 20, } resp = http_post( f"{api_base}{get_cloudmail_path()}", headers={ "Authorization": public_token, "Content-Type": "application/json", }, json=payload, timeout=20, ) resp.raise_for_status() try: data = resp.json() except Exception: raise Exception(f"Cloud Mail 邮件接口返回非JSON: {resp.text[:300]}") if not isinstance(data, dict): raise Exception(f"Cloud Mail 邮件接口返回格式错误: {data}") result_code = data.get("code") if result_code not in (None, 200, "200"): raise Exception( f"Cloud Mail 邮件接口失败: code={result_code}, message={data.get('message', '')}" ) messages = data.get("data") if isinstance(messages, list): return messages return _pick_list_payload(data) 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 os.path.join(os.path.dirname(__file__), "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 = os.path.abspath(resolve_grok2api_local_token_file()) pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() or "ssoBasic" parent = os.path.dirname(token_file) os.makedirs(parent, exist_ok=True) lock_path = token_file + ".lock" try: with open(lock_path, "a", encoding="utf-8"): pass os.chmod(lock_path, 0o600) except Exception: pass try: from filelock import FileLock except Exception as exc: raise RuntimeError(f"filelock 依赖不可用,拒绝非原子写入 token 池: {exc}") with FileLock(lock_path, timeout=30): 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 as exc: broken_path = token_file + f".broken-{int(time.time())}" try: os.replace(token_file, broken_path) except Exception: broken_path = token_file raise RuntimeError(f"本地 token 文件 JSON 解析失败,已停止写入以避免覆盖: {broken_path}: {exc}") if not isinstance(data, dict): raise RuntimeError("本地 token 文件根节点不是 JSON object,拒绝覆盖") pool = data.get(pool_name) if pool is None: pool = [] elif not isinstance(pool, list): raise RuntimeError(f"本地 token 池 {pool_name} 不是列表,拒绝覆盖") 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 pool.append({"token": token, "tags": ["auto-register"], "note": email}) data[pool_name] = pool if os.path.exists(token_file): backup_path = token_file + ".bak" try: with open(token_file, "rb") as src, open(backup_path, "wb") as dst: dst.write(src.read()) dst.flush() os.fsync(dst.fileno()) try: os.chmod(backup_path, 0o600) except Exception: pass except Exception as exc: raise RuntimeError(f"创建本地 token 备份失败,拒绝继续写入: {exc}") fd, temp_path = tempfile.mkstemp(prefix=".token-", suffix=".tmp", dir=parent) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") f.flush() os.fsync(f.fileno()) try: os.chmod(temp_path, 0o600) except Exception: pass os.replace(temp_path, token_file) temp_path = None try: os.chmod(token_file, 0o600) except Exception: pass finally: if temp_path and os.path.exists(temp_path): try: os.unlink(temp_path) except Exception: pass if log_callback: log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})") return True def get_grok2api_remote_api_bases(base): """生成 grok2api 管理 API 候选根路径。 参数: - base str: 用户配置的 grok2api 远端地址 返回: - list[str]: 依次尝试的管理 API 根路径 """ normalized = str(base or "").strip().rstrip("/") if not normalized: return [] lower = normalized.lower() candidates = [normalized] if lower.endswith("/admin/api"): return candidates if lower.endswith("/admin"): candidates.append(f"{normalized}/api") else: candidates.append(f"{normalized}/admin/api") seen = set() unique = [] for item in candidates: if item not in seen: unique.append(item) seen.add(item) return unique 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(config.get("grok2api_remote_app_key", "") or "").strip() pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() if not base or not app_key: raise RemoteTokenRequestError("grok2api 远端未配置 base/app_key") headers = {"Content-Type": "application/json"} query = {"app_key": app_key} remote_pool = {"ssoBasic": "basic", "ssoSuper": "super"}[pool_name] api_bases = get_grok2api_remote_api_bases(base) incompatible = [] add_payload = {"tokens": [token], "pool": remote_pool, "tags": ["auto-register"]} for api_base in api_bases: endpoint = f"{api_base}/tokens/add" try: response = http_post(endpoint, headers=headers, params=query, json=add_payload, timeout=30) except Exception as exc: raise RemoteTokenRequestError(f"远端 /tokens/add 网络请求失败: {endpoint}: {exc}") from exc status = int(getattr(response, "status_code", 0) or 0) if 200 <= status < 300: if log_callback: log_callback(f"[+] 已写入 grok2api 远端池: {pool_name} ({endpoint})") return True if status in (404, 405): incompatible.append(f"{endpoint}: HTTP {status}") continue body = str(getattr(response, "text", "") or "")[:300] raise RemoteTokenRequestError(f"远端 /tokens/add 请求失败,不允许全量回退: {endpoint}: HTTP {status}: {body}") if not bool(config.get("grok2api_allow_legacy_full_save", False)): raise RemoteTokenCompatibilityError( "/tokens/add 不受支持,旧版全量保存默认禁用以避免并发覆盖: " + "; ".join(incompatible) ) current = None fallback_base = None etag = None load_errors = [] for api_base in api_bases or [base]: endpoint = f"{api_base}/tokens" try: response = http_get(endpoint, headers=headers, params=query, timeout=20) except Exception as exc: raise RemoteTokenRequestError(f"旧版远端池读取网络失败: {endpoint}: {exc}") from exc status = int(getattr(response, "status_code", 0) or 0) if status != 200: load_errors.append(f"{endpoint}: HTTP {status}") continue payload = response.json() candidate = payload.get("tokens") if isinstance(payload, dict) and "tokens" in payload else payload if not isinstance(candidate, dict): load_errors.append(f"{endpoint}: unexpected payload") continue current = candidate fallback_base = api_base response_headers = getattr(response, "headers", {}) or {} etag = response_headers.get("ETag") or response_headers.get("etag") break if current is None or fallback_base is None: raise RemoteTokenRequestError("无法安全读取旧版远端 token 池: " + "; ".join(load_errors)) pool = current.get(pool_name) if pool is None: pool = [] elif not isinstance(pool, list): raise RemoteTokenRequestError(f"远端 token 池 {pool_name} 不是列表,拒绝全量覆盖") existing = { _normalize_sso_token(item if isinstance(item, str) else item.get("token", "")) for item in pool if isinstance(item, (str, dict)) } if token not in existing: pool.append({"token": token, "tags": ["auto-register"], "note": email}) current[pool_name] = pool if not etag: raise RemoteTokenCompatibilityError( "旧版远端接口未提供 ETag,无法保证并发安全,已拒绝全量保存" ) save_headers = dict(headers) save_headers["If-Match"] = etag endpoint = f"{fallback_base}/tokens" try: response = http_post(endpoint, headers=save_headers, params=query, json=current, timeout=30) except Exception as exc: raise RemoteTokenRequestError(f"旧版远端池保存网络失败: {endpoint}: {exc}") from exc status = int(getattr(response, "status_code", 0) or 0) if not 200 <= status < 300: raise RemoteTokenRequestError(f"旧版远端池保存失败: {endpoint}: HTTP {status}") if log_callback: log_callback(f"[+] 已写入 grok2api 远端池(旧版兼容): {pool_name} ({endpoint})") return True def add_token_to_grok2api_pools(raw_token, email="", log_callback=None): result = { "local": {"enabled": bool(config.get("grok2api_auto_add_local", False)), "ok": None, "error": None}, "remote": {"enabled": bool(config.get("grok2api_auto_add_remote", False)), "ok": None, "error": None}, } if result["local"]["enabled"]: try: result["local"]["ok"] = bool(add_token_to_grok2api_local_pool(raw_token, email=email, log_callback=log_callback)) except Exception as exc: result["local"]["ok"] = False result["local"]["error"] = log_exception("写入 grok2api 本地池失败", exc, log_callback) if result["remote"]["enabled"]: try: result["remote"]["ok"] = bool(add_token_to_grok2api_remote_pool(raw_token, email=email, log_callback=log_callback)) except Exception as exc: result["remote"]["ok"] = False result["remote"]["error"] = log_exception("写入 grok2api 远端池失败", exc, log_callback) return result def apply_browser_proxy_option(options, proxy): if not proxy: return if hasattr(options, "set_proxy"): try: options.set_proxy(proxy) return except Exception: pass if not hasattr(options, "set_argument"): raise AttributeError("当前 DrissionPage ChromiumOptions 不支持设置浏览器代理") try: options.set_argument(f"--proxy-server={proxy}") except TypeError: options.set_argument("--proxy-server", proxy) def create_browser_options(browser_proxy=""): options = ChromiumOptions() options.auto_port() options.set_timeouts(base=1) apply_browser_proxy_option(options, browser_proxy) if os.path.exists(EXTENSION_PATH): options.add_extension(EXTENSION_PATH) 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): request_kwargs = _build_request_kwargs(**kwargs) try: return requests.get(url, **request_kwargs) except Exception as exc: if request_kwargs.get("proxies") and is_proxy_connection_error(exc): retry_kwargs = dict(kwargs) retry_kwargs["proxies"] = {} return requests.get(url, **_build_request_kwargs(**retry_kwargs)) raise def http_post(url, **kwargs): request_kwargs = _build_request_kwargs(**kwargs) try: return requests.post(url, **request_kwargs) except Exception as exc: if request_kwargs.get("proxies") and is_proxy_connection_error(exc): 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 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 无已验证域名可用") def get_email_provider(): return config.get("email_provider", "duckmail") def get_email_and_token(api_key=None): provider = get_email_provider() if provider == "yyds": return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt()) if provider == "cloudmail": return cloudmail_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 == "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 == "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 cloudmail_get_oai_code( dev_token, email, timeout=180, poll_interval=3, log_callback=None, cancel_callback=None, resend_callback=None, ): # dev_token 是为了保持现有邮箱 Provider 调用契约;Cloud Mail 使用配置中的公共 Token。 _ = dev_token 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 = cloudmail_get_messages(email) except Exception as exc: if log_callback: log_callback(f"[Debug] Cloud Mail 拉取邮件列表失败: {exc}") sleep_with_cancel(poll_interval, cancel_callback) continue if log_callback: log_callback(f"[Debug] Cloud Mail 本轮邮件数量: {len(messages)}") for msg in messages: msg_id = msg.get("emailId") or msg.get("email_id") or msg.get("id") if not msg_id: continue attempt = int(seen_attempts.get(msg_id, 0)) if attempt >= 5: continue seen_attempts[msg_id] = attempt + 1 target_address = str( msg.get("toEmail") or msg.get("to_email") or "" ).strip().lower() if target_address and target_address != email.lower(): continue parts = [] code_value = str(msg.get("code", "") or "").strip() if code_value: parts.append(f"verification code: {code_value}") for field in ("text", "content", "html", "body", "snippet"): value = msg.get(field) values = value if isinstance(value, list) else [value] for item in values: if isinstance(item, str) and item.strip(): parts.append(re.sub(r"<[^>]+>", " ", item)) subject = str(msg.get("subject", "") or "") combined = "\n".join(parts) if log_callback: log_callback(f"[Debug] Cloud Mail 收到邮件: {subject}") code = extract_verification_code(combined, subject) if code: if log_callback: log_callback(f"[*] Cloud Mail 从邮件中提取到验证码: {code}") return code if log_callback: log_callback( f"[Debug] Cloud Mail 邮件已解析但未提取到验证码 " f"id={msg_id} attempt={seen_attempts[msg_id]}" ) sleep_with_cancel(poll_interval, cancel_callback) raise Exception(f"Cloud Mail 在 {timeout}s 内未收到验证码邮件") 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 response_preview(res, limit=200): try: text = str(res.text or "") except Exception: text = "" text = re.sub(r"\s+", " ", text).strip() return text[:limit] def is_cloudflare_block_response(res): try: headers = {str(k).lower(): str(v).lower() for k, v in dict(res.headers).items()} text = str(res.text or "").lower() server = headers.get("server", "") content_type = headers.get("content-type", "") return ( res.status_code in (403, 429, 503) and ( "cloudflare" in server or "cloudflare" in text or "cf-error" in text or "__cf_chl" in text or "text/html" in content_type ) ) except Exception: return False def set_birth_date(session, log_callback=None): url = "https://grok.com/rest/auth/set-birth-date" new_headers = { "content-type": "application/json", "origin": "https://grok.com", "referer": "https://grok.com/", } payload = {"birthDate": generate_random_birthdate()} try: res = session.post(url, json=payload, headers=new_headers, timeout=15) if log_callback: log_callback( f"[Debug] set_birth_date status: {res.status_code}, body: {response_preview(res)}" ) if 200 <= res.status_code < 300: return True, "ok" if is_cloudflare_block_response(res): return ( False, "set_birth_date 被 grok.com 的 Cloudflare 防护拦截,HTTP " f"{res.status_code}", ) return False, f"set_birth_date HTTP {res.status_code}: {response_preview(res)}" except Exception as e: if log_callback: log_callback(f"[set_birth_date] 异常: {e}") return False, f"set_birth_date 异常: {e}" def set_tos_accepted(session, log_callback=None): url = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion" payload = struct.pack("B", (2 << 3) | 0) + struct.pack("B", 1) data = b"\x00" + struct.pack(">I", len(payload)) + payload new_headers = { "content-type": "application/grpc-web+proto", "x-grpc-web": "1", "x-user-agent": "connect-es/2.1.1", "origin": "https://accounts.x.ai", "referer": "https://accounts.x.ai/accept-tos", } try: res = session.post(url, data=data, headers=new_headers, timeout=15) if log_callback: log_callback(f"[Debug] set_tos_accepted status: {res.status_code}") if 200 <= res.status_code < 300: return True, "ok" if is_cloudflare_block_response(res): return ( False, "set_tos_accepted 被 accounts.x.ai 的 Cloudflare 防护拦截,HTTP " f"{res.status_code}", ) return False, f"set_tos_accepted HTTP {res.status_code}: {response_preview(res)}" except Exception as e: if log_callback: log_callback(f"[set_tos_accepted] 异常: {e}") return False, f"set_tos_accepted 异常: {e}" def encode_grpc_nsfw_settings(): field1_content = bytes([0x10, 0x01]) field1 = bytes([0x0A, len(field1_content)]) + field1_content nsfw_string = b"always_show_nsfw_content" field2_inner = bytes([0x0A, len(nsfw_string)]) + nsfw_string field2 = bytes([0x12, len(field2_inner)]) + field2_inner payload = field1 + field2 return b"\x00" + struct.pack(">I", len(payload)) + payload def update_nsfw_settings(session, log_callback=None): url = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls" data = encode_grpc_nsfw_settings() new_headers = { "content-type": "application/grpc-web+proto", "x-grpc-web": "1", "origin": "https://grok.com", "referer": "https://grok.com/", } try: res = session.post(url, data=data, headers=new_headers, timeout=15) if log_callback: log_callback( f"[Debug] update_nsfw status: {res.status_code}, body: {response_preview(res)}" ) if 200 <= res.status_code < 300: return True, "ok" if is_cloudflare_block_response(res): return ( False, "update_nsfw_settings 被 grok.com 的 Cloudflare 防护拦截,HTTP " f"{res.status_code}", ) return False, f"update_nsfw_settings HTTP {res.status_code}: {response_preview(res)}" except Exception as e: if log_callback: log_callback(f"[update_nsfw] 异常: {e}") return False, f"update_nsfw_settings 异常: {e}" def enable_nsfw_for_token(token, cf_clearance="", log_callback=None): proxies = get_proxies() user_agent = get_user_agent() try: with requests.Session(impersonate="chrome120", proxies=proxies) as session: cookie_parts = [f"sso={token}", f"sso-rw={token}"] if cf_clearance: cookie_parts.append(f"cf_clearance={cf_clearance}") session.headers.update( { "user-agent": user_agent, "cookie": "; ".join(cookie_parts), } ) ok, message = set_tos_accepted(session, log_callback) if not ok: return False, message ok, message = set_birth_date(session, log_callback) if not ok: return False, message ok, message = update_nsfw_settings(session, log_callback) if not ok: return False, message return True, "成功开启 NSFW" except Exception as e: return False, f"异常: {str(e)}" SIGNUP_URL = "https://accounts.x.ai/sign-up?redirect=grok-com" browser = None page = None browser_proxy_bridge = None browser_started_with_proxy = False def setup_light_theme(root): try: root.option_add("*Background", UI_BG) root.option_add("*Foreground", UI_FG) root.option_add("*selectBackground", UI_ACTIVE_BG) root.option_add("*selectForeground", UI_FG) root.option_add("*insertBackground", UI_FG) root.option_add("*Entry.Background", UI_ENTRY_BG) root.option_add("*Text.Background", UI_ENTRY_BG) root.option_add("*Menu.Background", UI_ENTRY_BG) root.option_add("*Menu.Foreground", UI_FG) style = ttk.Style(root) available = set(style.theme_names()) if "clam" in available: style.theme_use("clam") elif "default" in available: style.theme_use("default") root.configure(bg=UI_BG) style.configure(".", background=UI_BG, foreground=UI_FG, fieldbackground=UI_ENTRY_BG) style.configure("TFrame", background=UI_BG) style.configure("TLabelframe", background=UI_BG, foreground=UI_FG) style.configure("TLabelframe.Label", background=UI_BG, foreground=UI_FG) style.configure("TLabel", background=UI_BG, foreground=UI_FG) style.configure("TCheckbutton", background=UI_BG, foreground=UI_FG) style.configure("TButton", background=UI_BUTTON_BG, foreground=UI_FG) style.configure("TEntry", fieldbackground=UI_ENTRY_BG, foreground=UI_FG) style.configure("TCombobox", fieldbackground=UI_ENTRY_BG, foreground=UI_FG) style.configure("TSpinbox", fieldbackground=UI_ENTRY_BG, foreground=UI_FG) except Exception: pass def tk_label(parent, text="", **kwargs): return tk.Label(parent, text=text, bg=kwargs.pop("bg", UI_BG), fg=kwargs.pop("fg", UI_FG), **kwargs) def tk_entry(parent, textvariable=None, width=30, **kwargs): return tk.Entry( parent, textvariable=textvariable, width=width, bg=UI_ENTRY_BG, fg=UI_FG, insertbackground=UI_FG, disabledbackground="#2f2f2f", disabledforeground=UI_MUTED_FG, highlightthickness=1, highlightbackground="#555555", relief=tk.SOLID, **kwargs, ) def tk_button(parent, text="", command=None, state="normal", **kwargs): return tk.Button( parent, text=text, command=command, state=state, bg=UI_BUTTON_BG, fg=UI_FG, activebackground=UI_ACTIVE_BG, activeforeground=UI_FG, disabledforeground="#777777", relief=tk.RAISED, padx=10, pady=3, **kwargs, ) def tk_checkbutton(parent, text="", variable=None, **kwargs): return tk.Checkbutton( parent, text=text, variable=variable, bg=UI_BG, fg=UI_FG, activebackground=UI_BG, activeforeground=UI_FG, selectcolor="#3d7be0", **kwargs, ) def tk_option_menu(parent, variable, values, width=12): menu = tk.OptionMenu(parent, variable, *values) menu.configure( width=width, bg=UI_ENTRY_BG, fg=UI_FG, activebackground=UI_ACTIVE_BG, activeforeground=UI_FG, highlightthickness=1, highlightbackground="#555555", relief=tk.SOLID, ) menu["menu"].configure(bg=UI_ENTRY_BG, fg=UI_FG, activebackground=UI_ACTIVE_BG, activeforeground=UI_FG) return menu def start_browser(log_callback=None, use_proxy=True): global browser, page, browser_proxy_bridge, browser_started_with_proxy last_exc = None proxy_enabled = bool(use_proxy and get_configured_proxy()) for attempt in range(1, 5): bridge = None try: browser_proxy, bridge = prepare_browser_proxy(use_proxy=use_proxy, log_callback=log_callback) browser = Chromium(create_browser_options(browser_proxy=browser_proxy)) browser_proxy_bridge = bridge browser_started_with_proxy = bool(browser_proxy) tabs = browser.get_tabs() page = tabs[-1] if tabs else browser.new_tab() if log_callback and getattr(browser, "user_data_path", None): log_callback(f"[Debug] 当前浏览器资料目录: {browser.user_data_path}") if log_callback and get_configured_proxy(): mode = "代理" if browser_started_with_proxy else "直连" log_callback(f"[*] 浏览器网络模式: {mode}") if log_callback and attempt > 1: log_callback(f"[*] 浏览器第 {attempt} 次启动成功") return browser, page except Exception as exc: last_exc = exc if bridge is not None: try: bridge.stop() except Exception: pass if log_callback: mode = "代理" if proxy_enabled else "直连" log_callback(f"[Debug] 浏览器{mode}启动失败(第{attempt}/4次): {exc}") try: if browser is not None: browser.quit(del_data=True) except Exception: pass browser = None page = None browser_proxy_bridge = None browser_started_with_proxy = False time.sleep(min(1.5 * attempt, 4)) raise Exception(f"浏览器启动失败,已重试4次: {last_exc}") def stop_browser(): global browser, page, browser_started_with_proxy if browser is not None: try: browser.quit(del_data=True) except Exception: pass stop_browser_proxy_bridge() browser = None page = None browser_started_with_proxy = False def restart_browser(log_callback=None, use_proxy=True): stop_browser() return start_browser(log_callback=log_callback, use_proxy=use_proxy) def cleanup_runtime_memory(log_callback=None, reason="定期清理"): if log_callback: log_callback(f"[*] {reason}: 关闭浏览器并清理内存") stop_browser() try: from cpa_xai.browser_confirm import shutdown_mint_browsers shutdown_mint_browsers() except Exception as exc: if log_callback: log_callback(f"[Debug] CPA 浏览器清理失败: {exc}") collected = gc.collect() if log_callback: log_callback(f"[*] Python GC 已回收对象数: {collected}") def refresh_active_page(): global browser, page if browser is None: restart_browser() try: tabs = browser.get_tabs() if tabs: page = tabs[-1] else: page = browser.new_tab() except Exception: restart_browser() return page def click_email_signup_button(timeout=10, log_callback=None, cancel_callback=None): global page deadline = time.time() + timeout while time.time() < deadline: raise_if_cancelled(cancel_callback) if log_callback: log_callback("[Debug] 尝试查找“使用邮箱注册”按钮...") clicked = page.run_js(r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function nodeText(node) { return [ node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('title'), node.getAttribute('href'), ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } function scoreEntry(node) { const compact = nodeText(node).replace(/\s+/g, ''); const lower = compact.toLowerCase(); if (compact.includes('使用邮箱注册')) return 100; if (lower.includes('signupwithemail')) return 95; if (lower.includes('continuewithemail')) return 90; if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80; if (lower === 'email' || lower.includes('邮箱')) return 70; return 0; } const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]')) .filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true') .map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score); const target = candidates[0]?.node || null; if (!target) { return false; } target.click(); return candidates[0].text || true; """) if clicked: if log_callback: detail = f": {clicked}" if isinstance(clicked, str) else "" log_callback(f"[*] 已点击「使用邮箱注册」按钮{detail}") sleep_with_cancel(2, cancel_callback) return True if log_callback: current_url = page.url if page else "none" log_callback(f"[Debug] 当前URL: {current_url}") sleep_with_cancel(1, cancel_callback) if log_callback: page_html = page.html[:500] if page else "no page" log_callback(f"[Debug] 页面内容片段: {page_html}") raise Exception("未找到「使用邮箱注册」按钮") def open_signup_page(log_callback=None, cancel_callback=None): global browser, page raise_if_cancelled(cancel_callback) if browser is None: start_browser(log_callback=log_callback) if log_callback: log_callback("[*] 浏览器已启动") def _open_with_current_browser(): global page try: page = browser.get_tab(0) page.get(SIGNUP_URL) except Exception as e: if log_callback: log_callback(f"[Debug] 打开URL异常: {e}") page = browser.new_tab(SIGNUP_URL) page.wait.doc_loaded() try: _open_with_current_browser() except Exception as e: if browser_started_with_proxy and get_configured_proxy(): if log_callback: log_callback(f"[!] 浏览器代理访问注册页失败,自动回退直连: {e}") restart_browser(log_callback=log_callback, use_proxy=False) _open_with_current_browser() else: raise if browser_started_with_proxy and page_has_proxy_error(page): if log_callback: log_callback("[!] 浏览器页面显示代理错误,自动回退直连") restart_browser(log_callback=log_callback, use_proxy=False) _open_with_current_browser() sleep_with_cancel(2, cancel_callback) if log_callback: log_callback(f"[*] 当前URL: {page.url}") click_email_signup_button( log_callback=log_callback, cancel_callback=cancel_callback ) def has_profile_form(log_callback=None): 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=45, log_callback=None, cancel_callback=None): raise_if_cancelled(cancel_callback) email, dev_token = get_email_and_token() if not email or not dev_token: raise Exception("获取邮箱失败") if log_callback: log_callback(f"[*] 已创建邮箱: {email}") deadline = time.time() + timeout last_diag_time = 0 last_reclick_time = 0 last_snapshot = None while time.time() < deadline: raise_if_cancelled(cancel_callback) filled = page.run_js( """ const email = arguments[0]; function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function textOf(node) { return [ node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('title'), node.getAttribute('placeholder'), node.getAttribute('data-testid'), node.getAttribute('name'), node.getAttribute('id'), node.getAttribute('autocomplete'), ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } function describeInput(node) { return [ `type=${node.getAttribute('type') || ''}`, `name=${node.getAttribute('name') || ''}`, `id=${node.getAttribute('id') || ''}`, `placeholder=${node.getAttribute('placeholder') || ''}`, `aria=${node.getAttribute('aria-label') || ''}`, `testid=${node.getAttribute('data-testid') || ''}`, ].join(' ').replace(/\s+/g, ' ').trim().slice(0, 160); } function describeAction(node) { return textOf(node).slice(0, 120); } function emailCandidates() { const direct = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"], input[placeholder*="mail" i], input[aria-label*="mail" i]')); const all = Array.from(document.querySelectorAll('input, textarea')); for (const node of all) { const type = (node.getAttribute('type') || '').toLowerCase(); if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'search'].includes(type)) continue; const meta = textOf(node).toLowerCase(); if (meta.includes('email') || meta.includes('e-mail') || meta.includes('mail') || meta.includes('邮箱') || meta.includes('电子邮件')) { direct.push(node); } } return Array.from(new Set(direct)); } const visibleInputs = Array.from(document.querySelectorAll('input, textarea')) .filter((node) => isVisible(node) && !node.disabled && !node.readOnly) .map(describeInput) .slice(0, 8); const visibleActions = Array.from(document.querySelectorAll('button, a, [role="button"]')) .filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true') .map(describeAction) .filter(Boolean) .slice(0, 10); const input = emailCandidates().find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null; if (!input) { return { state: 'not-ready', url: location.href, title: document.title, inputs: visibleInputs, buttons: visibleActions, }; } input.focus(); input.click(); const valueProto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const valueSetter = Object.getOwnPropertyDescriptor(valueProto, 'value')?.set; const tracker = input._valueTracker; if (tracker) tracker.setValue(''); if (valueSetter) valueSetter.call(input, email); else input.value = email; input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: email, inputType: 'insertText' })); input.dispatchEvent(new InputEvent('input', { bubbles: true, data: email, inputType: 'insertText' })); input.dispatchEvent(new Event('change', { bubbles: true })); const inputType = (input.getAttribute('type') || '').toLowerCase(); const isValid = inputType !== 'email' || input.checkValidity(); if ((input.value || '').trim() !== email || !isValid) { return { state: 'fill-failed', value: input.value || '', valid: isValid, input: describeInput(input), url: location.href, }; } input.blur(); return { state: 'filled', input: describeInput(input), url: location.href, }; """, email, ) state = filled.get("state") if isinstance(filled, dict) else filled if isinstance(filled, dict): last_snapshot = filled if state == "not-ready": now = time.time() if now - last_reclick_time >= 3: reclicked = page.run_js(r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function nodeText(node) { return [ node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('title'), node.getAttribute('href'), ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } function scoreEntry(node) { const compact = nodeText(node).replace(/\s+/g, ''); const lower = compact.toLowerCase(); if (compact.includes('使用邮箱注册')) return 100; if (lower.includes('signupwithemail')) return 95; if (lower.includes('continuewithemail')) return 90; if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80; if (lower === 'email' || lower.includes('邮箱')) return 70; return 0; } const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]')) .filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true') .map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score); if (!candidates.length) return false; candidates[0].node.click(); return candidates[0].text || true; """) last_reclick_time = now if reclicked and log_callback: detail = f": {reclicked}" if isinstance(reclicked, str) else "" log_callback(f"[Debug] 邮箱输入框未出现,已再次触发邮箱注册入口{detail}") if log_callback and now - last_diag_time >= 5: last_diag_time = now inputs = " | ".join((filled or {}).get("inputs", [])[:6]) if isinstance(filled, dict) else "" buttons = " | ".join((filled or {}).get("buttons", [])[:8]) if isinstance(filled, dict) else "" url = (filled or {}).get("url", page.url if page else "") if isinstance(filled, dict) else (page.url if page else "") log_callback(f"[Debug] 等待邮箱输入框: url={url}; inputs={inputs or 'none'}; buttons={buttons or 'none'}") sleep_with_cancel(0.5, cancel_callback) continue if state != "filled": if log_callback: log_callback(f"[Debug] 邮箱输入框已出现,但写入失败: {filled}") sleep_with_cancel(0.5, cancel_callback) continue sleep_with_cancel(0.8, cancel_callback) clicked = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function textOf(node) { return [ node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('title'), node.getAttribute('placeholder'), node.getAttribute('data-testid'), node.getAttribute('name'), node.getAttribute('id'), node.getAttribute('autocomplete'), ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } function emailCandidates() { const direct = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"], input[placeholder*="mail" i], input[aria-label*="mail" i]')); const all = Array.from(document.querySelectorAll('input, textarea')); for (const node of all) { const type = (node.getAttribute('type') || '').toLowerCase(); if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'search'].includes(type)) continue; const meta = textOf(node).toLowerCase(); if (meta.includes('email') || meta.includes('e-mail') || meta.includes('mail') || meta.includes('邮箱') || meta.includes('电子邮件')) { direct.push(node); } } return Array.from(new Set(direct)); } const input = emailCandidates().find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null; if (!input || !(input.value || '').trim()) return false; const inputType = (input.getAttribute('type') || '').toLowerCase(); if (inputType === 'email' && !input.checkValidity()) return false; const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')) .filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'); const submitButton = buttons.find((node) => { const text = textOf(node).replace(/\s+/g, ''); const lower = text.toLowerCase(); return ( text === '注册' || text.includes('注册') || text.includes('继续') || text.includes('下一步') || text.includes('确认') || lower.includes('signup') || lower.includes('sign up') || lower.includes('continue') || lower.includes('next') || lower.includes('createaccount') || lower.includes('submit') ); }); if (submitButton) { submitButton.click(); return textOf(submitButton) || true; } const form = input.closest('form'); if (form) { if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); return 'form-submit'; } input.focus(); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true })); input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true })); return 'enter'; """ ) if clicked: if log_callback: detail = f" ({clicked})" if isinstance(clicked, str) else "" log_callback(f"[*] 已填写邮箱并提交: {email}{detail}") return email, dev_token sleep_with_cancel(0.5, cancel_callback) if last_snapshot: inputs = " | ".join(last_snapshot.get("inputs", [])[:6]) buttons = " | ".join(last_snapshot.get("buttons", [])[:8]) url = last_snapshot.get("url", page.url if page else "") raise Exception( f"未找到邮箱输入框或注册按钮,最后页面: url={url}; inputs={inputs or 'none'}; buttons={buttons or 'none'}" ) raise Exception("未找到邮箱输入框或注册按钮") def fill_code_and_submit(email, dev_token, timeout=180, log_callback=None, cancel_callback=None): def _resend_code(): page.run_js( r""" const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')); const target = nodes.find((node) => { const t = (node.innerText || node.textContent || '').replace(/\s+/g, '').toLowerCase(); return t.includes('重新发送') || t.includes('resend') || t.includes('再次发送'); }); if (target && !target.disabled) { target.click(); return true; } return false; """ ) code = get_oai_code( dev_token, email, log_callback=log_callback, cancel_callback=cancel_callback, resend_callback=_resend_code, ) if not code: raise Exception("获取验证码失败") clean_code = str(code).replace("-", "").strip() deadline = time.time() + timeout while time.time() < deadline: raise_if_cancelled(cancel_callback) filled = page.run_js( """ const code = String(arguments[0] || '').trim(); if (!code) return 'empty-code'; function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function setInputValue(input, value) { const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; const tracker = input._valueTracker; if (tracker) tracker.setValue(''); if (nativeSetter) nativeSetter.call(input, value); else input.value = value; input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' })); input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' })); input.dispatchEvent(new Event('change', { bubbles: true })); } const aggregate = Array.from(document.querySelectorAll( 'input[data-input-otp=\"true\"], input[name=\"code\"], input[autocomplete=\"one-time-code\"], input[inputmode=\"numeric\"], input[inputmode=\"text\"]' )).find((node) => isVisible(node) && !node.disabled && !node.readOnly && Number(node.maxLength || 6) > 1); if (aggregate) { aggregate.focus(); aggregate.click(); setInputValue(aggregate, code); return String(aggregate.value || '').replace(/\\s+/g, '') ? 'filled-aggregate' : 'aggregate-failed'; } const otpBoxes = Array.from(document.querySelectorAll('input')).filter((node) => { if (!isVisible(node) || node.disabled || node.readOnly) return false; const maxLength = Number(node.maxLength || 0); const ac = String(node.autocomplete || '').toLowerCase(); return maxLength === 1 || ac === 'one-time-code'; }); if (otpBoxes.length >= code.length) { for (let i = 0; i < code.length; i += 1) { const ch = code[i] || ''; const box = otpBoxes[i]; box.focus(); box.click(); setInputValue(box, ch); box.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })); box.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })); } const merged = otpBoxes.slice(0, code.length).map((x) => String(x.value || '').trim()).join(''); return merged.length ? 'filled-boxes' : 'boxes-failed'; } return 'not-ready'; """, clean_code, ) if filled == "not-ready": sleep_with_cancel(0.5, cancel_callback) continue if "failed" in str(filled): if log_callback: log_callback(f"[Debug] 验证码填写失败: {filled}") sleep_with_cancel(0.5, cancel_callback) continue clicked = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const buttons = Array.from(document.querySelectorAll('button[type=\"submit\"], button')).filter((node) => { return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'; }); const btn = buttons.find((node) => { const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase(); return ( t.includes('确认邮箱') || t.includes('继续') || t.includes('下一步') || t.includes('confirm') || t.includes('continue') || t.includes('next') ); }); if (!btn) return 'no-button'; btn.focus(); btn.click(); return 'clicked'; """ ) if clicked == "clicked" or clicked == "no-button": if log_callback: log_callback(f"[*] 已填写验证码并提交: {code}") sleep_with_cancel(1.5, cancel_callback) return code sleep_with_cancel(0.5, cancel_callback) raise Exception("验证码已获取,但自动填写/提交失败") def getTurnstileToken(log_callback=None, cancel_callback=None): global 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 for _ in range(0, 20): 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"[*] 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): given_name, family_name, password = build_profile() 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"[*] 资料已填写,等待 Cloudflare 人机验证通过... 当前token长度={token_len}") if token_len == "0": pause_seconds = random.uniform(1, 3) if log_callback: log_callback(f"[*] 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("[*] 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 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"[*] 等待 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("[*] 提交前仍卡住,自动再次复用 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 sleep_with_cancel(0.8, cancel_callback) continue if submit_state == "submitted": if log_callback: log_callback(f"[*] 已填写注册资料并提交: {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 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 final_no_submit_state = "" final_no_submit_since = None final_no_submit_timeout = 25 last_wait_exception_message = "" last_wait_exception_at = 0.0 while time.time() < deadline: raise_if_cancelled(cancel_callback) try: refresh_active_page() if page is None: sleep_with_cancel(1, cancel_callback) continue # 仍停留在“完成注册”页时,若 Cloudflare 已通过,周期性重试点击提交 now = time.time() if now - last_submit_retry >= 2.5: retried = page.run_js( r""" function isVisible(node) { if (!node) return false; const style = window.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } const titleHit = !!Array.from(document.querySelectorAll('h1,h2,div,span')).find((el) => { const t = (el.textContent || '').replace(/\s+/g, ''); const lower = t.toLowerCase(); return t.includes('完成注册') || lower.includes('completeyoursignup') || lower.includes('completesignup'); }); if (!titleHit) return 'not-final-page'; const cfInput = document.querySelector('input[name="cf-turnstile-response"]'); const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]'); if (cfPresent) { const token = String((cfInput && cfInput.value) || '').trim(); const solved = token.length >= 80; if (!solved) return 'final-page-wait-cf:' + token.length; } function buttonText(node) { return [ node.innerText, node.textContent, node.getAttribute('value'), node.getAttribute('aria-label'), node.getAttribute('title'), ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => { return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true'; }); const submitBtn = buttons.find((node) => { const t = buttonText(node).replace(/\s+/g, '').toLowerCase(); return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount'); }); if (!submitBtn) { const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | '); return 'final-page-no-submit:' + visibleTexts; } submitBtn.focus(); submitBtn.click(); return 'final-page-clicked-submit'; """ ) last_submit_retry = now if log_callback and (retried == "final-page-clicked-submit" or (isinstance(retried, str) and retried.startswith("final-page-no-submit"))): log_callback(f"[Debug] 最终页状态: {retried}") if isinstance(retried, str) and retried.startswith("final-page-no-submit"): if retried != final_no_submit_state: final_no_submit_state = retried final_no_submit_since = now elif final_no_submit_since and now - final_no_submit_since >= final_no_submit_timeout: raise AccountRetryNeeded( f"最终注册页状态 {final_no_submit_timeout}s 未变化且未找到提交按钮,重试当前账号: {retried}" ) else: final_no_submit_state = "" final_no_submit_since = None if log_callback and isinstance(retried, str) and retried.startswith("final-page-wait-cf"): token_len = retried.split(":", 1)[1] if ":" in retried else "0" log_callback(f"[Debug] 最终页状态: final-page-wait-cf, token长度={token_len}") if now - last_cf_retry_at >= 10: if log_callback: log_callback("[*] 最终页 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 == "sso" and value: if log_callback: log_callback("[*] 已获取到 sso cookie") return value except PageDisconnectedError: refresh_active_page() except AccountRetryNeeded: raise except Exception as exc: if log_callback: now = time.time() message = f"{exc.__class__.__name__}: {exc}" if message != last_wait_exception_message or now - last_wait_exception_at >= 10: log_callback(f"[Debug] 等待 sso cookie 时出现异常,将继续等待: {message}") last_wait_exception_message = message last_wait_exception_at = now sleep_with_cancel(1, cancel_callback) raise Exception( f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}" ) def maybe_export_cpa_xai_after_success(email, password, sso="", log_callback=None, cancel_callback=None): if not bool(config.get("cpa_export_enabled", False)): return {"ok": False, "skipped": True, "reason": "disabled"} logger = log_callback or (lambda message: None) try: from cpa_export import export_cpa_xai_for_account except Exception as exc: logger(f"[!] CPA 模块导入失败,已跳过 OIDC 导出: {exc}") return {"ok": False, "error": str(exc)} current_page = None try: current_page = page except Exception: current_page = None try: result = export_cpa_xai_for_account( email=email, password=password, page=current_page, sso=sso, config=config, log_callback=logger, cancel_callback=cancel_callback, ) except Exception as exc: logger(f"[!] CPA OIDC 导出失败,账号已保留: {exc}") return {"ok": False, "error": str(exc)} if result.get("ok"): exported_path = result.get("hotload_path") or result.get("path") or "" suffix = f": {exported_path}" if exported_path else "" logger(f"[+] CPA OIDC 导出成功{suffix}") elif not result.get("skipped"): logger(f"[!] CPA OIDC 导出失败,账号已保留: {result.get('error') or result}") return result def _save_mail_credential(email, credential, log_callback=None): from account_outputs import save_mail_credential try: return save_mail_credential(os.path.dirname(__file__), email, credential) except Exception as exc: log_exception("保存邮箱凭据失败", exc, log_callback) return False def _append_account_line(path, email, password, sso): from account_outputs import append_account_line return append_account_line(path, email, password, sso) def _queue_unsaved_account(path, payload, error, log_callback=None): from account_outputs import queue_unsaved_account try: return queue_unsaved_account(path, payload, error) except Exception as exc: log_exception("写入账号 pending 队列失败", exc, log_callback) return False def retry_pending_file(pending_path, output_path=None, log_callback=None): from account_outputs import retry_pending_file as _retry_pending_file return _retry_pending_file(pending_path, output_path=output_path, log_callback=log_callback) def run_registration_common(count, log_callback, cancel_callback, accounts_output_file, observer): from registration_flow import RegistrationCallbacks, RegistrationOperations, run_batch callbacks = RegistrationCallbacks(log=log_callback, cancelled=cancel_callback) operations = RegistrationOperations( start_browser=lambda: start_browser(log_callback=log_callback), restart_browser=lambda: restart_browser(log_callback=log_callback), browser_missing=lambda: browser is None, open_signup_page=lambda: open_signup_page(log_callback=log_callback, cancel_callback=cancel_callback), fill_email_and_submit=lambda: fill_email_and_submit(log_callback=log_callback, cancel_callback=cancel_callback), save_mail_credential=lambda email, token: _save_mail_credential(email, token, log_callback), fill_code_and_submit=lambda email, token: fill_code_and_submit(email, token, log_callback=log_callback, cancel_callback=cancel_callback), fill_profile_and_submit=lambda: fill_profile_and_submit(log_callback=log_callback, cancel_callback=cancel_callback), wait_for_sso_cookie=lambda: wait_for_sso_cookie(log_callback=log_callback, cancel_callback=cancel_callback), enable_nsfw=lambda sso: enable_nsfw_for_token(sso, log_callback=log_callback), persist_account_line=lambda email, password, sso: _append_account_line(accounts_output_file, email, password, sso), queue_unsaved_result=lambda payload, error: _queue_unsaved_account(accounts_output_file, payload, error, log_callback), add_tokens=lambda sso, email: add_token_to_grok2api_pools(sso, email=email, log_callback=log_callback), export_cpa=lambda email, password, sso: maybe_export_cpa_xai_after_success( email=email, password=password, sso=sso, log_callback=log_callback, cancel_callback=cancel_callback, ), cleanup=lambda reason: cleanup_runtime_memory(log_callback=log_callback, reason=reason), sleep=lambda seconds: sleep_with_cancel(seconds, cancel_callback), cancelled_exception=RegistrationCancelled, retry_exception=AccountRetryNeeded, ) return run_batch( count=count, callbacks=callbacks, observer=observer, ops=operations, enable_nsfw=bool(config.get("enable_nsfw", True)), cleanup_interval=MEMORY_CLEANUP_INTERVAL, max_slot_retry=3, max_mail_retry=3, ) class GrokRegisterGUI: def __init__(self, root): self.root = root self.root.title("Grok 注册机") self.root.geometry("1120x900") self.root.minsize(960, 700) self.is_running = False self.batch_count = 0 self.success_count = 0 self.fail_count = 0 self.registered_unsaved_count = 0 self.postprocess_warning_count = 0 self.results = [] self.stop_requested = False self.ui_queue = queue.Queue() self.accounts_output_file = "" self.setup_ui() self.root.after(50, self.process_ui_queue) def setup_ui(self): load_config() main_frame = tk.Frame(self.root, bg=UI_BG, padx=10, pady=10) main_frame.pack(fill=tk.BOTH, expand=True) main_frame.grid_columnconfigure(0, weight=1) main_frame.grid_rowconfigure(3, weight=1) config_frame = tk.LabelFrame( main_frame, text="配置", bg=UI_PANEL_BG, fg=UI_FG, padx=10, pady=10, relief=tk.GROOVE, borderwidth=1, ) config_frame.grid(row=0, column=0, sticky=tk.EW, pady=(0, 8)) config_frame.grid_columnconfigure(1, weight=1, minsize=260) config_frame.grid_columnconfigure(3, weight=1, minsize=260) def add_label(row, column, text): tk_label(config_frame, text=text, bg=UI_PANEL_BG).grid( row=row, column=column, sticky=tk.W, padx=(0, 6), pady=3, ) def add_field(widget, row, column, columnspan=1, sticky=tk.EW): widget.grid( row=row, column=column, columnspan=columnspan, sticky=sticky, padx=(0, 14), pady=3, ) add_label(0, 0, "邮箱服务商:") self.email_provider_var = tk.StringVar(value=config.get("email_provider", "duckmail")) self.email_provider_combo = tk_option_menu(config_frame, self.email_provider_var, ["duckmail", "yyds", "cloudflare", "cloudmail"], width=12) add_field(self.email_provider_combo, 0, 1, sticky=tk.W) add_label(0, 2, "注册数量:") self.count_var = tk.StringVar(value=str(config.get("register_count", 1))) self.count_spinbox = tk.Spinbox( config_frame, from_=1, to=2500, width=8, textvariable=self.count_var, bg=UI_ENTRY_BG, fg=UI_FG, insertbackground=UI_FG, buttonbackground=UI_BUTTON_BG, disabledbackground="#2f2f2f", disabledforeground=UI_MUTED_FG, relief=tk.SOLID, ) add_field(self.count_spinbox, 0, 3, sticky=tk.W) add_label(1, 0, "注册选项:") self.nsfw_var = tk.BooleanVar(value=config.get("enable_nsfw", True)) self.nsfw_check = tk_checkbutton(config_frame, text="注册后开启 NSFW", variable=self.nsfw_var) add_field(self.nsfw_check, 1, 1, sticky=tk.W) add_label(1, 2, "代理(可选):") self.proxy_var = tk.StringVar(value=config.get("proxy", "")) self.proxy_entry = tk_entry(config_frame, textvariable=self.proxy_var, width=34) add_field(self.proxy_entry, 1, 3) add_label(2, 0, "DuckMail API Key:") self.api_key_var = tk.StringVar(value=config.get("duckmail_api_key", "")) self.api_key_entry = tk_entry(config_frame, textvariable=self.api_key_var, width=34) add_field(self.api_key_entry, 2, 1) add_label(2, 2, "Cloudflare 鉴权模式:") self.cloudflare_auth_mode_var = tk.StringVar(value=config.get("cloudflare_auth_mode", "none")) self.cloudflare_auth_mode_combo = tk_option_menu( config_frame, self.cloudflare_auth_mode_var, ["query-key", "bearer", "x-api-key", "x-admin-auth", "none"], width=12 ) add_field(self.cloudflare_auth_mode_combo, 2, 3, sticky=tk.W) add_label(3, 0, "Cloudflare API Base:") self.cloudflare_api_base_var = tk.StringVar(value=config.get("cloudflare_api_base", "")) self.cloudflare_api_base_entry = tk_entry(config_frame, textvariable=self.cloudflare_api_base_var, width=72) add_field(self.cloudflare_api_base_entry, 3, 1, columnspan=3) add_label(4, 0, "Cloudflare API Key:") self.cloudflare_api_key_var = tk.StringVar(value=config.get("cloudflare_api_key", "")) self.cloudflare_api_key_entry = tk_entry(config_frame, textvariable=self.cloudflare_api_key_var, width=34) add_field(self.cloudflare_api_key_entry, 4, 1) add_label(4, 2, "CF 路径:") self.cloudflare_paths_var = tk.StringVar( value=",".join( [ config.get("cloudflare_path_domains", "/api/domains"), config.get("cloudflare_path_accounts", "/api/new_address"), config.get("cloudflare_path_token", "/api/token"), config.get("cloudflare_path_messages", "/api/mails"), ] ) ) self.cloudflare_paths_entry = tk_entry(config_frame, textvariable=self.cloudflare_paths_var, width=34) add_field(self.cloudflare_paths_entry, 4, 3) add_label(5, 0, "Cloud Mail API Base:") self.cloudmail_api_base_var = tk.StringVar(value=config.get("cloudmail_api_base", "")) self.cloudmail_api_base_entry = tk_entry(config_frame, textvariable=self.cloudmail_api_base_var, width=34) add_field(self.cloudmail_api_base_entry, 5, 1) add_label(5, 2, "Cloud Mail 域名:") self.cloudmail_domains_var = tk.StringVar(value=config.get("cloudmail_domains", "")) self.cloudmail_domains_entry = tk_entry(config_frame, textvariable=self.cloudmail_domains_var, width=34) add_field(self.cloudmail_domains_entry, 5, 3) add_label(6, 0, "Cloud Mail Public Token:") self.cloudmail_public_token_var = tk.StringVar(value=config.get("cloudmail_public_token", "")) self.cloudmail_public_token_entry = tk_entry(config_frame, textvariable=self.cloudmail_public_token_var, width=72) add_field(self.cloudmail_public_token_entry, 6, 1, columnspan=3) add_label(7, 0, "grok2api 本地入池:") self.grok2api_local_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_local", True))) self.grok2api_local_auto_check = tk_checkbutton(config_frame, variable=self.grok2api_local_auto_var) add_field(self.grok2api_local_auto_check, 7, 1, sticky=tk.W) add_label(7, 2, "grok2api 池名:") self.grok2api_pool_name_var = tk.StringVar(value=str(config.get("grok2api_pool_name", "ssoBasic"))) self.grok2api_pool_name_combo = tk_option_menu( config_frame, self.grok2api_pool_name_var, ["ssoBasic", "ssoSuper"], width=12 ) add_field(self.grok2api_pool_name_combo, 7, 3, sticky=tk.W) add_label(8, 0, "本地 token.json:") self.grok2api_local_file_var = tk.StringVar(value=str(config.get("grok2api_local_token_file", ""))) self.grok2api_local_file_entry = tk_entry(config_frame, textvariable=self.grok2api_local_file_var, width=72) add_field(self.grok2api_local_file_entry, 8, 1, columnspan=3) add_label(9, 0, "grok2api 远端入池:") self.grok2api_remote_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_remote", False))) self.grok2api_remote_auto_check = tk_checkbutton(config_frame, variable=self.grok2api_remote_auto_var) add_field(self.grok2api_remote_auto_check, 9, 1, sticky=tk.W) add_label(10, 0, "grok2api 远端 Base:") self.grok2api_remote_base_var = tk.StringVar(value=str(config.get("grok2api_remote_base", ""))) self.grok2api_remote_base_entry = tk_entry(config_frame, textvariable=self.grok2api_remote_base_var, width=72) add_field(self.grok2api_remote_base_entry, 10, 1, columnspan=3) add_label(11, 0, "grok2api 远端 app_key:") self.grok2api_remote_key_var = tk.StringVar(value=str(config.get("grok2api_remote_app_key", ""))) self.grok2api_remote_key_entry = tk_entry(config_frame, textvariable=self.grok2api_remote_key_var, width=72) add_field(self.grok2api_remote_key_entry, 11, 1, columnspan=3) add_label(12, 0, "OIDC / CPA:") self.cpa_export_var = tk.BooleanVar(value=bool(config.get("cpa_export_enabled", False))) self.cpa_export_check = tk_checkbutton(config_frame, text="注册成功后导出 CPA xAI OIDC", variable=self.cpa_export_var) add_field(self.cpa_export_check, 12, 1, sticky=tk.W) add_label(12, 2, "CPA 输出目录:") self.cpa_auth_dir_var = tk.StringVar(value=str(config.get("cpa_auth_dir", "./cpa_auths"))) self.cpa_auth_dir_entry = tk_entry(config_frame, textvariable=self.cpa_auth_dir_var, width=34) add_field(self.cpa_auth_dir_entry, 12, 3) btn_frame = tk.Frame(main_frame, bg=UI_BG) btn_frame.grid(row=1, column=0, sticky=tk.EW, pady=(0, 6)) self.start_btn = tk_button(btn_frame, text="开始注册", command=self.start_registration) self.start_btn.pack(side=tk.LEFT, padx=5) self.stop_btn = tk_button(btn_frame, text="停止", command=self.stop_registration, state=tk.DISABLED) self.stop_btn.pack(side=tk.LEFT, padx=5) self.clear_btn = tk_button(btn_frame, text="清空日志", command=self.clear_log) self.clear_btn.pack(side=tk.LEFT, padx=5) status_frame = tk.Frame(main_frame, bg=UI_BG) status_frame.grid(row=2, column=0, sticky=tk.EW, pady=(0, 6)) self.status_var = tk.StringVar(value="就绪") tk_label(status_frame, text="状态: ").pack(side=tk.LEFT) self.status_label = tk.Label(status_frame, textvariable=self.status_var, bg=UI_BG, fg="green") self.status_label.pack(side=tk.LEFT) self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0 | 待恢复: 0 | 后处理警告: 0") tk.Label(status_frame, textvariable=self.stats_var, bg=UI_BG, fg=UI_FG).pack(side=tk.RIGHT) log_frame = tk.LabelFrame( main_frame, text="日志", bg=UI_PANEL_BG, fg=UI_FG, padx=5, pady=5, relief=tk.GROOVE, borderwidth=1, ) log_frame.grid(row=3, column=0, sticky=tk.NSEW) log_frame.grid_columnconfigure(0, weight=1) log_frame.grid_rowconfigure(0, weight=1) self.log_text = scrolledtext.ScrolledText( log_frame, height=18, width=60, bg="#111111", fg="#f5f5f5", insertbackground="#f5f5f5", selectbackground="#345a8a", selectforeground="#ffffff", relief=tk.SOLID, borderwidth=1, highlightthickness=1, highlightbackground="#555555", ) self.log_text.grid(row=0, column=0, sticky=tk.NSEW) self.log("[*] GUI 已就绪,配置已加载") self.log(f"[*] 当前邮箱服务商: {self.email_provider_var.get()} | 注册数量: {self.count_var.get()}") def process_ui_queue(self): try: while True: event = self.ui_queue.get_nowait() kind = event[0] if kind == "log": line = event[1] self.log_text.insert(tk.END, f"{line}\n") self.log_text.see(tk.END) elif kind == "clear_log": self.log_text.delete(1.0, tk.END) elif kind == "stats": self.stats_var.set(f"成功: {event[1]} | 失败: {event[2]} | 待恢复: {event[3]} | 后处理警告: {event[4]}") elif kind == "running": running = bool(event[1]) 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") elif kind == "error": messagebox.showerror(event[1], event[2]) except queue.Empty: pass except Exception as exc: print(f"[!] UI 队列处理失败: {exc}", file=sys.stderr) finally: try: self.root.after(50, self.process_ui_queue) except Exception: pass def log(self, message): timestamp = datetime.datetime.now().strftime("%H:%M:%S") line = f"[{timestamp}] {message}" print(line, flush=True) self.ui_queue.put(("log", line)) def clear_log(self): self.ui_queue.put(("clear_log",)) def update_stats(self): self.ui_queue.put(("stats", self.success_count, self.fail_count, self.registered_unsaved_count, self.postprocess_warning_count)) def _set_running_ui(self, running): self.is_running = bool(running) self.ui_queue.put(("running", self.is_running)) 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["enable_nsfw"] = bool(self.nsfw_var.get()) 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 "none" config["cloudmail_api_base"] = self.cloudmail_api_base_var.get().strip() config["cloudmail_public_token"] = self.cloudmail_public_token_var.get().strip() config["cloudmail_domains"] = self.cloudmail_domains_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["cpa_export_enabled"] = bool(self.cpa_export_var.get()) config["cpa_auth_dir"] = self.cpa_auth_dir_var.get().strip() or "./cpa_auths" 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]) try: count = int(self.count_var.get()) config["register_count"] = count validated = validate_run_requirements(config) config.clear() config.update(validated) save_config() except (ValueError, ConfigError) as exc: self.log(f"[!] 配置无效或保存失败: {exc}") 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) self.log(f"[*] 配置已保存,开始执行。目标数量: {count}") self.log(f"[*] 成功账号将实时保存到: {self.accounts_output_file}") threading.Thread( target=self.run_registration, args=(count,), daemon=True, ).start() def stop_registration(self): self.stop_requested = True self.log("[!] 用户停止注册") def run_registration(self, count): def observer(batch, account, output): self.success_count = batch.success_count self.fail_count = batch.fail_count self.registered_unsaved_count = batch.registered_unsaved_count self.postprocess_warning_count = batch.postprocess_warning_count if account is not None: self.results.append({"email": account.email, "sso": account.sso, "profile": account.profile, "output": output}) self.update_stats() try: batch = run_registration_common( count=count, log_callback=self.log, cancel_callback=self.should_stop, accounts_output_file=self.accounts_output_file, observer=observer, ) self.success_count = batch.success_count self.fail_count = batch.fail_count self.registered_unsaved_count = batch.registered_unsaved_count self.postprocess_warning_count = batch.postprocess_warning_count self.update_stats() except Exception as exc: log_exception("任务异常", exc, self.log) finally: self._set_running_ui(False) self.log("[*] 任务结束") class CliStopController: def __init__(self): self.stop_requested = False def should_stop(self): return self.stop_requested def stop(self): self.stop_requested = True def cli_log(message): timestamp = datetime.datetime.now().strftime("%H:%M:%S") print(f"[{timestamp}] {message}", flush=True) def run_registration_cli(count): controller = CliStopController() accounts_output_file = os.path.join( os.path.dirname(__file__), f"accounts_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt", ) cli_log(f"[*] 终端模式启动,目标数量: {count}") cli_log(f"[*] 成功账号将实时保存到: {accounts_output_file}") last_stats = {"success": 0, "fail": 0, "pending": 0, "warnings": 0} def observer(batch, account, output): last_stats["success"] = batch.success_count last_stats["fail"] = batch.fail_count last_stats["pending"] = batch.registered_unsaved_count last_stats["warnings"] = batch.postprocess_warning_count cli_log(f"[*] 当前统计: 成功 {batch.success_count} | 失败 {batch.fail_count} | 待恢复 {batch.registered_unsaved_count} | 后处理警告 {batch.postprocess_warning_count}") try: batch = run_registration_common( count=count, log_callback=cli_log, cancel_callback=controller.should_stop, accounts_output_file=accounts_output_file, observer=observer, ) last_stats["success"] = batch.success_count last_stats["fail"] = batch.fail_count last_stats["pending"] = batch.registered_unsaved_count last_stats["warnings"] = batch.postprocess_warning_count except KeyboardInterrupt: controller.stop() cli_log("[!] 收到 Ctrl+C,正在停止并清理") except Exception as exc: log_exception("任务异常", exc, cli_log) finally: cli_log(f"[*] 任务结束。成功 {last_stats['success']} | 失败 {last_stats['fail']} | 待恢复 {last_stats['pending']} | 后处理警告 {last_stats['warnings']}") def main_cli(): try: load_config() except ConfigError as exc: cli_log(f"[!] {exc}") return try: validated = validate_run_requirements(config) config.clear() config.update(validated) except ConfigError as exc: cli_log(f"[!] {exc}") return count = int(config.get("register_count", 1) or 1) cli_log("[*] CLI 已加载配置") cli_log(f"[*] 当前邮箱服务商: {config.get('email_provider', 'duckmail')} | 注册数量: {count}") cli_log("[*] 输入 start 后开始;按 Ctrl+C 可强制停止") try: command = input("> ").strip().lower() except KeyboardInterrupt: cli_log("[!] 已取消") return if command != "start": cli_log("[!] 未输入 start,已退出") return run_registration_cli(count) def main(): if len(sys.argv) > 1 and sys.argv[1].strip().lower() == "retry-pending": if len(sys.argv) < 3: print("用法: python grok_register_ttk.py retry-pending [输出文件]", file=sys.stderr) return try: summary = retry_pending_file( sys.argv[2], output_path=sys.argv[3] if len(sys.argv) > 3 else None, log_callback=cli_log, ) cli_log( f"[*] pending 恢复完成: 已恢复 {summary['restored']} | 剩余 {summary['remaining']} | 输出 {summary['output_path']}" ) except Exception as exc: log_exception("pending 恢复失败", exc, cli_log) return if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"): main_cli() return if not TK_AVAILABLE: print(f"[!] GUI 模式需要 Tkinter,但当前环境不可用: {TK_IMPORT_ERROR}", file=sys.stderr) print("[*] 可改用 CLI 模式: python grok_register_ttk.py cli", file=sys.stderr) return root = tk.Tk() setup_light_theme(root) try: app = GrokRegisterGUI(root) except ConfigError as exc: print(f"[!] {exc}", file=sys.stderr) try: messagebox.showerror("配置错误", str(exc)) except Exception: pass root.destroy() return root.mainloop() if __name__ == "__main__": main()