From b845be012ae7d3889bc26f5f2826b4698e3e2387 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:10:13 +0000 Subject: [PATCH] refactor: modularize registration runtime safely --- .../temp-full-safe-modularization.yml | 65 - .github/workflows/temp-refactor-inventory.yml | 26 - account_outputs.py | 270 ++ app_config.py | 232 ++ browser_runtime.py | 190 + cf_mail_debug.py | 239 +- cpa_export.py | 199 +- cpa_xai/browser_confirm.py | 340 +- cpa_xai/browser_session.py | 359 ++ grok_register_ttk.py | 3102 ++------------- mail_service.py | 988 +++++ refactor_inventory.json | 3423 ----------------- registration_browser.py | 1300 +++++++ tests/test_browser_session.py | 36 + tests/test_cpa_core.py | 32 + tests/test_module_compatibility.py | 32 + tests/test_oauth_device.py | 69 + tests/test_registration_flow.py | 37 + tools/apply_full_safe_modularization.py | 1247 ------ tools/build_refactor_inventory.py | 55 - tools/fix_modularization_ast_spans.py | 35 - tools/fix_modularization_public_compat.py | 130 - tools/fix_modularization_shared_state.py | 57 - 23 files changed, 3945 insertions(+), 8518 deletions(-) delete mode 100644 .github/workflows/temp-full-safe-modularization.yml delete mode 100644 .github/workflows/temp-refactor-inventory.yml create mode 100644 app_config.py create mode 100644 browser_runtime.py create mode 100644 cpa_xai/browser_session.py create mode 100644 mail_service.py delete mode 100644 refactor_inventory.json create mode 100644 registration_browser.py create mode 100644 tests/test_browser_session.py create mode 100644 tests/test_cpa_core.py create mode 100644 tests/test_module_compatibility.py create mode 100644 tests/test_oauth_device.py delete mode 100644 tools/apply_full_safe_modularization.py delete mode 100644 tools/build_refactor_inventory.py delete mode 100644 tools/fix_modularization_ast_spans.py delete mode 100644 tools/fix_modularization_public_compat.py delete mode 100644 tools/fix_modularization_shared_state.py diff --git a/.github/workflows/temp-full-safe-modularization.yml b/.github/workflows/temp-full-safe-modularization.yml deleted file mode 100644 index 93e6395..0000000 --- a/.github/workflows/temp-full-safe-modularization.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Temporary full safe modularization - -on: - push: - paths: - - tools/apply_full_safe_modularization.py - - tools/fix_modularization_ast_spans.py - - tools/fix_modularization_shared_state.py - - tools/fix_modularization_public_compat.py - - .github/workflows/temp-full-safe-modularization.yml - workflow_dispatch: - -permissions: - contents: write - -jobs: - modularize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install project dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi - python -m pip install filelock - - name: Preflight migration scripts - run: | - python -m py_compile \ - tools/apply_full_safe_modularization.py \ - tools/fix_modularization_ast_spans.py \ - tools/fix_modularization_shared_state.py \ - tools/fix_modularization_public_compat.py - - name: Repair AST source spans - run: python tools/fix_modularization_ast_spans.py - - name: Repair shared state compatibility - run: python tools/fix_modularization_shared_state.py - - name: Repair public compatibility - run: python tools/fix_modularization_public_compat.py - - name: Validate patched migration script - run: python -m py_compile tools/apply_full_safe_modularization.py - - name: Apply safe modularization - run: python tools/apply_full_safe_modularization.py - - name: Verify Python syntax - run: python -m compileall -q . - - name: Run full unit test suite - run: python -m unittest discover -s tests -p 'test_*.py' - - name: Commit modularized source and cleanup - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm -f \ - tools/apply_full_safe_modularization.py \ - tools/fix_modularization_ast_spans.py \ - tools/fix_modularization_shared_state.py \ - tools/fix_modularization_public_compat.py \ - tools/build_refactor_inventory.py \ - refactor_inventory.json \ - .github/workflows/temp-refactor-inventory.yml \ - .github/workflows/temp-full-safe-modularization.yml - git add -A - git commit -m "refactor: modularize registration runtime safely" - git push diff --git a/.github/workflows/temp-refactor-inventory.yml b/.github/workflows/temp-refactor-inventory.yml deleted file mode 100644 index d80da14..0000000 --- a/.github/workflows/temp-refactor-inventory.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Temporary refactor inventory - -on: - push: - paths: - - tools/build_refactor_inventory.py - - .github/workflows/temp-refactor-inventory.yml - workflow_dispatch: - -permissions: - contents: write - -jobs: - inventory: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build AST inventory - run: python tools/build_refactor_inventory.py - - name: Commit inventory - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add refactor_inventory.json - git commit -m "chore: generate temporary refactor inventory" - git push diff --git a/account_outputs.py b/account_outputs.py index ed54ed1..ccc0ba2 100644 --- a/account_outputs.py +++ b/account_outputs.py @@ -2,6 +2,7 @@ import json import os import tempfile +import time from datetime import datetime, timezone from filelock import FileLock @@ -123,3 +124,272 @@ def retry_pending_file(pending_path, output_path=None, log_callback=None): if temp_path and os.path.exists(temp_path): os.unlink(temp_path) return {"restored": restored, "remaining": len(unresolved), "output_path": target_path} + + +# Token-pool runtime dependencies are injected by the application adapter. +config = {} +_http_get = None +_http_post = None +_log_exception = None +_remote_compat_error = RuntimeError +_remote_request_error = RuntimeError + + +def configure_token_runtime(config_ref, http_get, http_post, log_exception, + compatibility_error=RuntimeError, request_error=RuntimeError): + global config, _http_get, _http_post, _log_exception + global _remote_compat_error, _remote_request_error + config = config_ref + _http_get = http_get + _http_post = http_post + _log_exception = log_exception + _remote_compat_error = compatibility_error + _remote_request_error = request_error + globals()["http_get"] = http_get + globals()["http_post"] = http_post + globals()["log_exception"] = log_exception + globals()["RemoteTokenCompatibilityError"] = compatibility_error + globals()["RemoteTokenRequestError"] = request_error + + +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 + diff --git a/app_config.py b/app_config.py new file mode 100644 index 0000000..ca5abc2 --- /dev/null +++ b/app_config.py @@ -0,0 +1,232 @@ +"""Application configuration loading, normalization, and validation.""" +import json +import os +import tempfile +import urllib.parse + +CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + +DEFAULT_CONFIG = { + "duckmail_api_key": "", + "cloudflare_api_base": "", + "cloudflare_api_key": "", + "cloudflare_auth_mode": "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() + +class ConfigError(RuntimeError): + pass + + +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 _replace_config(value): + config.clear() + config.update(value) + return config + + +def load_config(): + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as handle: + loaded = json.load(handle) + return _replace_config(validate_config_structure(loaded)) + except ConfigError: + raise + except Exception as exc: + raise ConfigError(f"配置文件解析失败: {CONFIG_FILE}: {exc}") from exc + return _replace_config(validate_config_structure(DEFAULT_CONFIG.copy())) + + +def save_config(): + normalized = validate_config_structure(config) + _replace_config(normalized) + 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 handle: + fd = None + json.dump(config, handle, indent=4, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.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 + return config diff --git a/browser_runtime.py b/browser_runtime.py new file mode 100644 index 0000000..4d83181 --- /dev/null +++ b/browser_runtime.py @@ -0,0 +1,190 @@ +"""Shared HTTP, proxy, and Chromium option helpers.""" +import os +import urllib.parse + +from DrissionPage import ChromiumOptions +from curl_cffi import requests +from cpa_xai.proxyutil import ( + LocalAuthProxyBridge, + prepare_chromium_proxy, + proxy_for_chromium, +) + +_config = {} +_extension_path = "" + + +def configure_runtime(config_ref, extension_path=""): + global _config, _extension_path + _config = config_ref + _extension_path = str(extension_path or "") + + +def get_configured_proxy(): + return str(_config.get("proxy", "") or "").strip() + + +def get_proxies(): + proxy = get_configured_proxy() + return {"http": proxy, "https": proxy} if proxy else {} + + +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 = "[%s]" % host + port = _safe_proxy_port(parsed) + netloc = "%s:%s" % (host, port) if port else host + stripped = urllib.parse.urlunsplit((parsed.scheme or "http", netloc, parsed.path, parsed.query, parsed.fragment)) + return stripped.split("://", 1)[1] if "://" not in raw else 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.extend(["%s:%s" % (parsed.hostname, port), "port %s" % port]) + return [item.lower() for item in terms if item] + + +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(item in err for item in ("proxy", "tunnel", "socks")): + return True + markers = ( + "could not connect", "failed to connect", "connection refused", + "connection reset", "connect error", "timed out", "timeout", + ) + if any(item in err for item in markers): + terms = _proxy_endpoint_terms() + return not terms or any(term in err for term in terms) + 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 = "%s\n%s\n%s" % (url, title, body) + text = text.lower() + return any(marker in text for marker in ( + "err_proxy", "proxy connection failed", "proxy server", + "proxy authentication", "tunnel connection failed", + "无法连接到代理服务器", "代理服务器", + )) + + +def prepare_browser_proxy(use_proxy=True, log_callback=None): + proxy = get_configured_proxy() + if not use_proxy or not proxy: + return "", None + parsed = _parse_proxy_url(proxy) + if _proxy_has_auth(proxy) and parsed and (parsed.scheme or "http").lower() not in ("http", "https"): + stripped = _strip_proxy_auth(proxy) + if log_callback: + log_callback("[!] Chromium 暂不直接支持该认证代理协议,已使用去认证代理地址,失败将回退直连") + return stripped, None + logger = None + if log_callback: + logger = lambda message: log_callback("[*] 已为 Chromium启动本地认证代理桥: %s" % message.split(": ", 1)[-1]) if "started authenticated proxy bridge" in message else log_callback(message) + return prepare_chromium_proxy(proxy, log=logger) + + +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("--proxy-server=%s" % 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 _extension_path and 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 is_proxy_connection_error(exc): + direct = dict(request_kwargs) + direct.pop("proxies", None) + return requests.get(url, **direct) + raise + + +def http_post(url, **kwargs): + request_kwargs = _build_request_kwargs(**kwargs) + try: + return requests.post(url, **request_kwargs) + except Exception as exc: + if is_proxy_connection_error(exc): + direct = dict(request_kwargs) + direct.pop("proxies", None) + return requests.post(url, **direct) + raise diff --git a/cf_mail_debug.py b/cf_mail_debug.py index 962960c..fe1581d 100755 --- a/cf_mail_debug.py +++ b/cf_mail_debug.py @@ -1,198 +1,47 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - import argparse -import re -import secrets -import string import time -from typing import Any, Dict, List, Optional, Tuple from curl_cffi import requests +from mail_service import CloudflareMailClient, extract_verification_code -def extract_code(text: str, subject: str = "") -> Optional[str]: - if subject: - m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", subject, re.IGNORECASE) - if m: - return m.group(1) - m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE) - if m: - return m.group(1) - for p in [ - 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})", - ]: - m = re.search(p, text, re.IGNORECASE) - if m: - return m.group(1) - return None - - -def json_or_text(resp: requests.Response) -> Tuple[Optional[Dict[str, Any]], str]: +def create_address(api_base, auth_mode="none", api_key="", create_path="/api/new_address", + domain="", name="", timeout=20): + import mail_service as _mail_service + client = CloudflareMailClient( + api_base, auth_mode=auth_mode, api_key=api_key, + create_path=create_path, timeout=timeout, + ) + original_requests = _mail_service.requests + _mail_service.requests = requests try: - data = resp.json() - return data, "" - except Exception: - return None, (resp.text or "")[:400] - - -def generate_username(length: int = 10) -> str: - """生成 cloudflare_temp_email admin API 需要的随机邮箱名称。""" - chars = string.ascii_lowercase + string.digits - return "".join(secrets.choice(chars) for _ in range(length)) - - -def normalize_path(path: str, default_path: str) -> str: - """标准化 API 路径,避免漏写开头斜杠。""" - raw = (path or default_path).strip() or default_path - return raw if raw.startswith("/") else f"/{raw}" - - -def build_auth_headers(auth_mode: str, api_key: str, content_type: bool = False) -> Dict[str, str]: - """按调试参数构造 Cloudflare 临时邮箱接口鉴权请求头。""" - headers = {"Content-Type": "application/json"} if content_type else {} - key = (api_key or "").strip() - mode = (auth_mode or "none").strip().lower() - if not key: - return headers - if mode == "x-admin-auth": - headers["x-admin-auth"] = key - elif mode == "x-api-key": - headers["X-API-Key"] = key - elif mode == "bearer": - headers["Authorization"] = f"Bearer {key}" - return headers - - -def create_address( - api_base: str, - auth_mode: str = "none", - api_key: str = "", - create_path: str = "/api/new_address", - domain: str = "", - name: str = "", -) -> Tuple[str, str]: - """创建 Cloudflare 临时邮箱地址,支持匿名 API 和 admin API。""" - path = normalize_path(create_path, "/api/new_address") - is_admin_create = path.rstrip("/").lower() == "/admin/new_address" - if is_admin_create: - payload: Dict[str, Any] = { - "name": name.strip() if name.strip() else generate_username(), - "enablePrefix": True, - } - if domain.strip(): - payload["domain"] = domain.strip() - headers = build_auth_headers(auth_mode, api_key, content_type=True) - else: - payload = {} - if domain.strip(): - payload["domain"] = domain.strip() - headers = {"Content-Type": "application/json"} - resp = requests.post( - f"{api_base.rstrip('/')}{path}", - json=payload, - headers=headers, - timeout=20, - ) - resp.raise_for_status() - data, raw = json_or_text(resp) - if not data: - raise RuntimeError(f"{path} 非JSON: {raw}") - address = str(data.get("address", "")).strip() - jwt = str(data.get("jwt", "")).strip() - if not address or not jwt: - raise RuntimeError(f"{path} 缺少 address/jwt: {data}") - return address, jwt - - -def fetch_box(api_base: str, jwt: str, path: str, params: Dict[str, Any]) -> List[Dict[str, Any]]: - resp = requests.get( - f"{api_base.rstrip('/')}{path}", - params=params, - headers={"Authorization": f"Bearer {jwt}"}, - timeout=20, - ) - if resp.status_code >= 400: - return [] - data, _ = json_or_text(resp) - if not isinstance(data, dict): - return [] - if isinstance(data.get("results"), list): - return data["results"] - if isinstance(data.get("data"), list): - return data["data"] - if isinstance(data.get("messages"), list): - return data["messages"] - return [] - - -def probe_all_boxes(api_base: str, jwt: str) -> List[Tuple[str, List[Dict[str, Any]]]]: - probes = [ - ("/api/mails", {"limit": 20, "offset": 0}), - ("/api/sendbox", {"limit": 20, "offset": 0}), - ("/api/mails", {"limit": 20, "offset": 0, "box": "trash"}), - ("/api/mails", {"limit": 20, "offset": 0, "folder": "trash"}), - ("/api/mails", {"limit": 20, "offset": 0, "deleted": "1"}), - ("/api/mails", {"limit": 20, "offset": 0, "status": "deleted"}), - ] - out: List[Tuple[str, List[Dict[str, Any]]]] = [] - for path, params in probes: - mails = fetch_box(api_base, jwt, path, params) - out.append((f"{path}?{params}", mails)) - return out - - -def get_detail(api_base: str, jwt: str, mail_id: Any) -> Dict[str, Any]: - for url in [ - f"{api_base.rstrip('/')}/api/mail/{mail_id}", - f"{api_base.rstrip('/')}/api/mails/{mail_id}", - ]: - try: - resp = requests.get(url, headers={"Authorization": f"Bearer {jwt}"}, timeout=20) - if resp.status_code >= 400: - continue - data, _ = json_or_text(resp) - if isinstance(data, dict): - return data - except Exception: - continue - return {} - - -def flatten_mail_text(item: Dict[str, Any], detail: Dict[str, Any]) -> Tuple[str, str]: - subject = str(item.get("subject") or detail.get("subject") or "") - parts: List[str] = [] - for src in (item, detail): - for k in ("text", "raw", "content", "intro", "body", "snippet"): - v = src.get(k) - if isinstance(v, str) and v.strip(): - parts.append(v) - html_val = src.get("html") - if isinstance(html_val, str): - html_val = [html_val] - if isinstance(html_val, list): - for h in html_val: - if isinstance(h, str): - parts.append(re.sub(r"<[^>]+>", " ", h)) - return subject, "\n".join(parts) + return client.create_address(domain=domain, name=name) + finally: + _mail_service.requests = original_requests def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--api-base", required=True) - ap.add_argument("--address", default="") - ap.add_argument("--credential", default="") - ap.add_argument("--auth-mode", default="none", choices=["none", "bearer", "x-api-key", "x-admin-auth"]) - ap.add_argument("--api-key", default="") - ap.add_argument("--create-path", default="/api/new_address") - ap.add_argument("--domain", default="") - ap.add_argument("--name", default="") - ap.add_argument("--timeout", type=int, default=180) - ap.add_argument("--interval", type=int, default=3) - args = ap.parse_args() + parser = argparse.ArgumentParser() + parser.add_argument("--api-base", required=True) + parser.add_argument("--address", default="") + parser.add_argument("--credential", default="") + parser.add_argument("--auth-mode", default="none", choices=["none", "bearer", "x-api-key", "x-admin-auth"]) + parser.add_argument("--api-key", default="") + parser.add_argument("--create-path", default="/api/new_address") + parser.add_argument("--domain", default="") + parser.add_argument("--name", default="") + parser.add_argument("--timeout", type=int, default=180) + parser.add_argument("--interval", type=int, default=3) + args = parser.parse_args() + client = CloudflareMailClient( + args.api_base, + auth_mode=args.auth_mode, + api_key=args.api_key, + create_path=args.create_path, + ) address = args.address.strip() credential = args.credential.strip() if not credential: @@ -204,31 +53,31 @@ def main(): domain=args.domain, name=args.name, ) - print(f"[NEW] address={address}") - print(f"[NEW] credential(jwt)={credential}") + print("[NEW] address=%s" % address) + print("[NEW] credential(jwt)=%s" % credential) else: - print(f"[USE] address={address or '(unknown, from credential)'}") + print("[USE] address=%s" % (address or "(unknown, from credential)")) deadline = time.time() + max(args.timeout, 1) seen_ids = set() while time.time() < deadline: - boxes = probe_all_boxes(args.api_base, credential) + boxes = client.probe_all_boxes(credential) total = 0 - for name, mails in boxes: + for box_name, mails in boxes: if mails: - print(f"[BOX] {name} -> {len(mails)}") + print("[BOX] %s -> %s" % (box_name, len(mails))) total += len(mails) - for m in mails: - mail_id = m.get("id") or m.get("mail_id") + for item in mails: + mail_id = item.get("id") or item.get("mail_id") if not mail_id or mail_id in seen_ids: continue seen_ids.add(mail_id) - detail = get_detail(args.api_base, credential, mail_id) - subj, text = flatten_mail_text(m, detail) - code = extract_code(text, subj) - print(f"[MAIL] id={mail_id} subject={subj!r} code={code!r}") + detail = client.get_detail(credential, mail_id) + subject, text = client.flatten_mail_text(item, detail) + code = extract_verification_code(text, subject) + print("[MAIL] id=%s subject=%r code=%r" % (mail_id, subject, code)) if code: - print(f"[FOUND] {code}") + print("[FOUND] %s" % code) return if total == 0: print("[INFO] no mails yet") diff --git a/cpa_export.py b/cpa_export.py index b44b5d2..4daca4f 100644 --- a/cpa_export.py +++ b/cpa_export.py @@ -1,27 +1,88 @@ """Optional post-registration CPA/OIDC export hook.""" - +from dataclasses import dataclass +import importlib.util import os import shutil import sys import time from pathlib import Path - _ROOT = Path(__file__).resolve().parent _DEFAULT_AUTH_DIR = _ROOT / "cpa_auths" -def _ensure_cpa_xai_on_path(tools_dir=None): - if tools_dir: - tools = Path(tools_dir).expanduser().resolve() - else: - env_value = str(os.environ.get("API_REVERSE_TOOLS") or "").strip() - tools = Path(env_value).expanduser().resolve() if env_value else _ROOT - if tools.name == "cpa_xai" and (tools / "__init__.py").is_file(): - tools = tools.parent - if str(tools) not in sys.path: - sys.path.insert(0, str(tools)) - return tools +@dataclass(frozen=True) +class CpaExportSettings: + enabled: bool + auth_dir: Path + hotload_dir: Path | None + copy_to_hotload: bool + proxy: str + headless: bool + mint_timeout: float + request_timeout: float + poll_timeout: float + base_url: str + force_standalone: bool + cookie_inject: bool + tools_dir: str + + @classmethod + def from_config(cls, config): + cfg = dict(config or {}) + auth_dir = Path(cfg.get("cpa_auth_dir") or _DEFAULT_AUTH_DIR).expanduser() + if not auth_dir.is_absolute(): + auth_dir = (_ROOT / auth_dir).resolve() + hotload_value = str(cfg.get("cpa_hotload_dir") or "").strip() + hotload_dir = Path(hotload_value).expanduser() if hotload_value else None + if hotload_dir is not None and not hotload_dir.is_absolute(): + hotload_dir = (_ROOT / hotload_dir).resolve() + return cls( + enabled=bool(cfg.get("cpa_export_enabled", False)), + auth_dir=auth_dir, + hotload_dir=hotload_dir, + copy_to_hotload=bool(cfg.get("cpa_copy_to_hotload", False)), + proxy=str(cfg.get("cpa_proxy") or cfg.get("proxy") or "").strip(), + headless=bool(cfg.get("cpa_headless", False)), + mint_timeout=float(cfg.get("cpa_mint_timeout_sec") or 300), + request_timeout=float(cfg.get("cpa_oidc_request_timeout_sec") or 15), + poll_timeout=float(cfg.get("cpa_oidc_poll_timeout_sec") or 15), + base_url=str(cfg.get("cpa_base_url") or "https://cli-chat-proxy.grok.com/v1").strip(), + force_standalone=bool(cfg.get("cpa_force_standalone", True)), + cookie_inject=bool(cfg.get("cpa_mint_cookie_inject", True)), + tools_dir=str(cfg.get("api_reverse_tools") or "").strip(), + ) + + +def _load_mint_and_export(tools_dir=""): + tools_value = str(tools_dir or "").strip() + if not tools_value: + from cpa_xai import mint_and_export + return mint_and_export + tools = Path(tools_value).expanduser().resolve() + package = tools if tools.name == "cpa_xai" else tools / "cpa_xai" + init_path = package / "__init__.py" + if package.resolve() == (_ROOT / "cpa_xai").resolve(): + from cpa_xai import mint_and_export + return mint_and_export + if not init_path.is_file(): + raise ImportError("cpa_xai package not found under %s" % tools) + module_name = "_external_cpa_xai_%s" % abs(hash(str(package))) + spec = importlib.util.spec_from_file_location( + module_name, + str(init_path), + submodule_search_locations=[str(package)], + ) + if spec is None or spec.loader is None: + raise ImportError("unable to load cpa_xai from %s" % package) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + raise + return module.mint_and_export def export_cookies_from_page(page): @@ -48,101 +109,63 @@ def export_cookies_from_page(page): cookies = browser.cookies() except Exception: cookies = None - if isinstance(cookies, list): - return [item for item in cookies if isinstance(item, dict)] - return [] + return [item for item in cookies if isinstance(item, dict)] if isinstance(cookies, list) else [] -def export_cpa_xai_for_account( - email, - password, - page=None, - cookies=None, - sso=None, - config=None, - log_callback=None, - cancel_callback=None, -): - cfg = dict(config or {}) +def _normalize_result(result, email=""): + value = dict(result or {}) + value.setdefault("ok", False) + value.setdefault("skipped", False) + value.setdefault("email", str(email or "")) + value.setdefault("error", None) + return value + + +def export_cpa_xai_for_account(email, password, page=None, cookies=None, sso=None, + config=None, log_callback=None, cancel_callback=None): + settings = CpaExportSettings.from_config(config) log = log_callback or (lambda message: None) - if not bool(cfg.get("cpa_export_enabled", False)): - return {"ok": False, "skipped": True, "reason": "disabled"} - tools_dir = cfg.get("api_reverse_tools") or None - _ensure_cpa_xai_on_path(tools_dir) + if not settings.enabled: + return _normalize_result({"ok": False, "skipped": True, "reason": "disabled"}, email) try: - from cpa_xai import mint_and_export + mint_and_export = _load_mint_and_export(settings.tools_dir) except Exception as exc: log("[cpa] import cpa_xai failed: %s" % exc) - return {"ok": False, "error": "import: %s" % exc} - - auth_dir = Path(cfg.get("cpa_auth_dir") or _DEFAULT_AUTH_DIR).expanduser() - if not auth_dir.is_absolute(): - auth_dir = (_ROOT / auth_dir).resolve() - hotload_dir_value = str(cfg.get("cpa_hotload_dir") or "").strip() - hotload_dir = Path(hotload_dir_value).expanduser() if hotload_dir_value else None - if hotload_dir is not None and not hotload_dir.is_absolute(): - hotload_dir = (_ROOT / hotload_dir).resolve() - - proxy = str(cfg.get("cpa_proxy") or cfg.get("proxy") or "").strip() - headless = bool(cfg.get("cpa_headless", False)) - timeout = float(cfg.get("cpa_mint_timeout_sec") or 300) - request_timeout = float(cfg.get("cpa_oidc_request_timeout_sec") or 15) - poll_timeout = float(cfg.get("cpa_oidc_poll_timeout_sec") or 15) - base_url = str(cfg.get("cpa_base_url") or "https://cli-chat-proxy.grok.com/v1").strip() - force_standalone = bool(cfg.get("cpa_force_standalone", True)) - cookie_inject = bool(cfg.get("cpa_mint_cookie_inject", True)) + return _normalize_result({"ok": False, "error": "import: %s" % exc}, email) use_cookies = cookies - if use_cookies is None and cookie_inject and page is not None: + if use_cookies is None and settings.cookie_inject and page is not None: use_cookies = export_cookies_from_page(page) - if not cookie_inject: + if not settings.cookie_inject: use_cookies = None elif sso: base = list(use_cookies) if isinstance(use_cookies, list) else [] sso_value = str(sso).strip() for cookie_name in ("sso", "sso-rw"): for domain in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai", "grok.com", ".grok.com"): - base.append( - { - "name": cookie_name, - "value": sso_value, - "domain": domain, - "path": "/", - "secure": True, - "httpOnly": True, - } - ) + base.append({"name": cookie_name, "value": sso_value, "domain": domain, + "path": "/", "secure": True, "httpOnly": True}) use_cookies = base - auth_dir.mkdir(parents=True, exist_ok=True) - log("[cpa] mint OIDC for %s -> %s" % (email, auth_dir)) - - def _log(message): - log("[cpa] %s" % message) - + settings.auth_dir.mkdir(parents=True, exist_ok=True) + log("[cpa] mint OIDC for %s -> %s" % (email, settings.auth_dir)) result = mint_and_export( - email=email, - password=password, - auth_dir=auth_dir, - page=None if force_standalone else page, - proxy=proxy or None, - headless=headless, - base_url=base_url, - browser_timeout_sec=timeout, - force_standalone=force_standalone, - cookies=use_cookies, - reuse_browser=True, - recycle_every=15, - log=_log, - cancel=cancel_callback, - request_timeout_sec=request_timeout, - poll_timeout_sec=poll_timeout, + email=email, password=password, auth_dir=settings.auth_dir, + page=None if settings.force_standalone else page, + proxy=settings.proxy or None, headless=settings.headless, + base_url=settings.base_url, browser_timeout_sec=settings.mint_timeout, + force_standalone=settings.force_standalone, cookies=use_cookies, + reuse_browser=True, recycle_every=15, + log=lambda message: log("[cpa] %s" % message), cancel=cancel_callback, + request_timeout_sec=settings.request_timeout, + poll_timeout_sec=settings.poll_timeout, ) - if result.get("ok") and result.get("path") and bool(cfg.get("cpa_copy_to_hotload", False)) and hotload_dir: + result = _normalize_result(result, email) + if result.get("ok") and result.get("path") and settings.copy_to_hotload and settings.hotload_dir: try: - hotload_dir.mkdir(parents=True, exist_ok=True) + settings.hotload_dir.mkdir(parents=True, exist_ok=True) source = Path(result["path"]) - target = hotload_dir / source.name + target = settings.hotload_dir / source.name shutil.copy2(str(source), str(target)) try: os.chmod(str(target), 0o600) @@ -154,7 +177,7 @@ def export_cpa_xai_for_account( result["cpa_copy_error"] = str(exc) log("[cpa] hotload copy failed: %s" % exc) if not result.get("ok"): - fail_path = auth_dir / "cpa_auth_failed.txt" + fail_path = settings.auth_dir / "cpa_auth_failed.txt" try: with open(str(fail_path), "a", encoding="utf-8") as handle: handle.write("%s----%s----%s\n" % (email, result.get("error") or "unknown", int(time.time()))) diff --git a/cpa_xai/browser_confirm.py b/cpa_xai/browser_confirm.py index 65d3e86..d2c9571 100644 --- a/cpa_xai/browser_confirm.py +++ b/cpa_xai/browser_confirm.py @@ -19,17 +19,18 @@ from urllib.parse import urlparse LogFn = Callable[[str], None] +from .browser_session import ( + BrowserConfirmError, _noop_log, _sleep, create_standalone_page, close_standalone, + clear_page_session, normalize_cookies, inject_cookies, acquire_mint_browser, + release_mint_browser, shutdown_mint_browsers, +) -def _noop_log(_: str) -> None: - return None + + -class BrowserConfirmError(RuntimeError): - pass -def _sleep(sec: float) -> None: - time.sleep(sec) def _project_root() -> Path: @@ -115,355 +116,28 @@ def _is_turnstile_challenge(text: str) -> bool: return any(needle in source or needle in lower for needle in needles) -def create_standalone_page(proxy: Optional[str] = None, headless: bool = False, log: Optional[LogFn] = None): - logger = log or _noop_log - try: - from DrissionPage import Chromium, ChromiumOptions - except ImportError as exc: - raise BrowserConfirmError("DrissionPage not installed") from exc - - options = None - package_root = Path(__file__).resolve().parents[1] - try: - register_file = package_root / "grok_register_ttk.py" - if register_file.is_file(): - register_dir = str(package_root) - if register_dir not in sys.path: - sys.path.insert(0, register_dir) - try: - from grok_register_ttk import create_browser_options # type: ignore - - options = create_browser_options() - logger("using register create_browser_options (turnstilePatch)") - except Exception as exc: # noqa: BLE001 - logger("register browser options unavailable: %s" % exc) - options = None - except Exception as exc: # noqa: BLE001 - logger("register options probe failed: %s" % exc) - options = None - - if options is None: - options = ChromiumOptions() - options.auto_port() - options.set_timeouts(base=2) - for flag in ( - "--disable-gpu", - "--no-sandbox", - "--disable-dev-shm-usage", - "--mute-audio", - "--no-first-run", - "--disable-background-networking", - "--window-size=1280,900", - ): - options.set_argument(flag) - extension = str(package_root / "turnstilePatch") - if os.path.isdir(extension): - try: - options.add_extension(extension) - logger("added extension %s" % extension) - except Exception as exc: # noqa: BLE001 - logger("extension add failed: %s" % exc) - - if headless: - try: - options.headless(True) - except Exception: - options.set_argument("--headless=new") - logger("headless=True (may hit Cloudflare / break real clicks)") - else: - try: - options.headless(False) - except Exception: - pass - - for candidate in ( - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - ): - if os.path.isfile(candidate): - try: - options.set_browser_path(candidate) - except Exception: - pass - break - - from .proxyutil import prepare_chromium_proxy, proxy_log_label, resolve_proxy - - resolved = resolve_proxy(proxy) - proxy_bridge = None - browser = None - chrome_proxy, proxy_bridge = prepare_chromium_proxy(resolved, log=logger) - try: - if chrome_proxy: - options.set_argument("--proxy-server=%s" % chrome_proxy) - logger("browser proxy=%s (chromium %s)" % (proxy_log_label(resolved), chrome_proxy)) - else: - logger("browser proxy=(none)") - browser = Chromium(options) - if proxy_bridge is not None: - setattr(browser, "_cpa_proxy_bridge", proxy_bridge) - page = browser.latest_tab - _register_mint_browser(browser) - logger("standalone chromium started") - return browser, page - except Exception: - if browser is not None: - close_standalone(browser) - if proxy_bridge is not None: - try: - proxy_bridge.stop() - except Exception: - pass - raise -def close_standalone(browser: Any) -> None: - if browser is None: - return - _unregister_mint_browser(browser) - bridge = getattr(browser, "_cpa_proxy_bridge", None) - try: - browser.quit() - except Exception: - pass - if bridge is not None: - try: - bridge.stop() - except Exception: - pass -_mint_tls = threading.local() -_mint_registry_lock = threading.Lock() -_mint_registry = set() -def _register_mint_browser(browser: Any) -> None: - if browser is None: - return - with _mint_registry_lock: - _mint_registry.add(browser) -def _unregister_mint_browser(browser: Any) -> None: - if browser is None: - return - with _mint_registry_lock: - _mint_registry.discard(browser) -def _mint_tls_get(): - state = getattr(_mint_tls, "state", None) - if state is None: - state = {"browser": None, "page": None, "served": 0, "proxy": None, "headless": None} - _mint_tls.state = state - return state -def clear_page_session(page: Any, browser: Optional[Any] = None, log: Optional[LogFn] = None) -> None: - logger = log or _noop_log - try: - if page is not None: - try: - page.get("about:blank") - except Exception: - pass - for javascript in ( - "try{localStorage.clear()}catch(e){}", - "try{sessionStorage.clear()}catch(e){}", - ): - try: - page.run_js(javascript) - except Exception: - pass - for target in (page, browser): - if target is None: - continue - try: - target.set.cookies.clear() # type: ignore[attr-defined] - logger("mint session cookies cleared") - break - except Exception: - try: - cookies = target.cookies() - if isinstance(cookies, list): - for cookie in cookies: - try: - target.set.cookies.remove(cookie) # type: ignore[attr-defined] - except Exception: - pass - except Exception: - pass - except Exception as exc: - logger("clear_page_session: %s" % exc) -def normalize_cookies(cookies: Any): - output = [] - if not cookies: - return output - if isinstance(cookies, dict): - for name, value in cookies.items(): - if name and value is not None: - output.append({"name": str(name), "value": str(value), "domain": ".x.ai", "path": "/"}) - cookies = output - output = [] - if not isinstance(cookies, (list, tuple)): - return output - for cookie in cookies: - if not isinstance(cookie, dict): - continue - name = cookie.get("name") or cookie.get("Name") - value = cookie.get("value") or cookie.get("Value") - if not name or value is None: - continue - domain = str(cookie.get("domain") or cookie.get("Domain") or ".x.ai") - path = str(cookie.get("path") or cookie.get("Path") or "/") - item = {"name": str(name), "value": str(value), "domain": domain, "path": path} - for source, target in ( - ("expiry", "expiry"), - ("expires", "expiry"), - ("secure", "secure"), - ("httpOnly", "httpOnly"), - ("sameSite", "sameSite"), - ): - if source in cookie and cookie[source] is not None: - item[target] = cookie[source] - output.append(item) - sso_names = {"sso", "sso-rw", "cf_clearance", "sso_jwt", "__cf_bm"} - extras = [] - seen = {(item["name"], item["domain"], item["path"]) for item in output} - for item in list(output): - name = item["name"] - if name not in sso_names and not name.startswith("sso"): - continue - for domain in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai"): - key = (name, domain, item["path"]) - if key in seen: - continue - clone = dict(item) - clone["domain"] = domain - extras.append(clone) - seen.add(key) - output.extend(extras) - return output -def inject_cookies(page: Any, cookies: Any, log: Optional[LogFn] = None) -> int: - logger = log or _noop_log - items = normalize_cookies(cookies) - if not items or page is None: - return 0 - for url in ("https://accounts.x.ai/", "https://auth.x.ai/", "https://grok.com/"): - try: - page.get(url) - _sleep(0.4) - except Exception: - continue - count = 0 - for target_name, target in (("page", page), ("browser", getattr(page, "browser", None))): - if target is None: - continue - try: - target.set.cookies(items) # type: ignore[attr-defined] - count = len(items) - logger("injected cookies bulk via %s=%s" % (target_name, count)) - break - except Exception as exc: - logger("bulk set via %s failed: %s" % (target_name, exc)) - if count == 0: - for item in items: - ok = False - for target in (page, getattr(page, "browser", None)): - if target is None: - continue - try: - target.set.cookies(item) # type: ignore[attr-defined] - ok = True - break - except Exception: - continue - if ok: - count += 1 - logger("injected cookies one-by-one=%s/%s" % (count, len(items))) - try: - javascript_items = [ - cookie - for cookie in items - if not bool(cookie.get("httpOnly")) and str(cookie.get("name") or "").startswith("sso") - ] - if javascript_items: - page.run_js( - """ - const items = arguments[0] || []; - for (const c of items) { - let cookie = `${c.name}=${c.value}; path=${c.path || '/'}; domain=${c.domain || '.x.ai'}`; - if (c.secure !== false) cookie += '; Secure'; - document.cookie = cookie; - } - return document.cookie; - """, - javascript_items, - ) - logger("injected non-httpOnly sso cookies via document.cookie") - except Exception as exc: - logger("document.cookie injection failed: %s" % exc) - return count -def acquire_mint_browser(proxy: Optional[str] = None, headless: bool = False, reuse: bool = True, recycle_every: int = 15, log: Optional[LogFn] = None): - logger = log or _noop_log - state = _mint_tls_get() - if not reuse: - browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) - return browser, page, True - proxy_key = str(proxy or "") - if state["browser"] is None: - browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) - state.update({"browser": browser, "page": page, "served": 0, "proxy": proxy_key, "headless": bool(headless)}) - return browser, page, False - if state["proxy"] != proxy_key or bool(state["headless"]) != bool(headless): - try: - close_standalone(state["browser"]) - except Exception: - pass - browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) - state.update({"browser": browser, "page": page, "served": 0, "proxy": proxy_key, "headless": bool(headless)}) - return browser, page, False - if recycle_every and state["served"] and state["served"] % max(int(recycle_every), 1) == 0: - try: - close_standalone(state["browser"]) - except Exception: - pass - browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) - state.update({"browser": browser, "page": page, "served": 0, "proxy": proxy_key, "headless": bool(headless)}) - return browser, page, False - clear_page_session(state["page"], browser=state["browser"], log=logger) - return state["browser"], state["page"], False -def release_mint_browser(owned: bool, success: bool, log: Optional[LogFn] = None) -> None: - logger = log or _noop_log - if owned: - return - state = _mint_tls_get() - if success: - state["served"] = int(state.get("served", 0) or 0) + 1 - logger("mint browser served=%s" % state["served"]) -def shutdown_mint_browsers() -> None: - state = _mint_tls_get() - with _mint_registry_lock: - browsers = list(_mint_registry) - for browser in browsers: - try: - close_standalone(browser) - except Exception: - pass - state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None}) def _page_url(page: Any) -> str: diff --git a/cpa_xai/browser_session.py b/cpa_xai/browser_session.py new file mode 100644 index 0000000..101ba1a --- /dev/null +++ b/cpa_xai/browser_session.py @@ -0,0 +1,359 @@ +"""CPA Chromium session lifecycle, reuse, cleanup, and cookies.""" +from __future__ import annotations + +import os +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable, Optional + +LogFn = Callable[[str], None] +_mint_tls = threading.local() +_mint_registry_lock = threading.Lock() +_mint_registry = set() + +def _noop_log(_: str) -> None: + return None + +class BrowserConfirmError(RuntimeError): + pass + +def _sleep(sec: float) -> None: + time.sleep(sec) + +def create_standalone_page(proxy: Optional[str] = None, headless: bool = False, log: Optional[LogFn] = None): + logger = log or _noop_log + try: + from DrissionPage import Chromium, ChromiumOptions + except ImportError as exc: + raise BrowserConfirmError("DrissionPage not installed") from exc + + options = None + package_root = Path(__file__).resolve().parents[1] + try: + register_file = package_root / "grok_register_ttk.py" + if register_file.is_file(): + register_dir = str(package_root) + if register_dir not in sys.path: + sys.path.insert(0, register_dir) + try: + from grok_register_ttk import create_browser_options # type: ignore + + options = create_browser_options() + logger("using register create_browser_options (turnstilePatch)") + except Exception as exc: # noqa: BLE001 + logger("register browser options unavailable: %s" % exc) + options = None + except Exception as exc: # noqa: BLE001 + logger("register options probe failed: %s" % exc) + options = None + + if options is None: + options = ChromiumOptions() + options.auto_port() + options.set_timeouts(base=2) + for flag in ( + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage", + "--mute-audio", + "--no-first-run", + "--disable-background-networking", + "--window-size=1280,900", + ): + options.set_argument(flag) + extension = str(package_root / "turnstilePatch") + if os.path.isdir(extension): + try: + options.add_extension(extension) + logger("added extension %s" % extension) + except Exception as exc: # noqa: BLE001 + logger("extension add failed: %s" % exc) + + if headless: + try: + options.headless(True) + except Exception: + options.set_argument("--headless=new") + logger("headless=True (may hit Cloudflare / break real clicks)") + else: + try: + options.headless(False) + except Exception: + pass + + for candidate in ( + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + ): + if os.path.isfile(candidate): + try: + options.set_browser_path(candidate) + except Exception: + pass + break + + from .proxyutil import prepare_chromium_proxy, proxy_log_label, resolve_proxy + + resolved = resolve_proxy(proxy) + proxy_bridge = None + browser = None + chrome_proxy, proxy_bridge = prepare_chromium_proxy(resolved, log=logger) + try: + if chrome_proxy: + options.set_argument("--proxy-server=%s" % chrome_proxy) + logger("browser proxy=%s (chromium %s)" % (proxy_log_label(resolved), chrome_proxy)) + else: + logger("browser proxy=(none)") + browser = Chromium(options) + if proxy_bridge is not None: + setattr(browser, "_cpa_proxy_bridge", proxy_bridge) + page = browser.latest_tab + _register_mint_browser(browser) + logger("standalone chromium started") + return browser, page + except Exception: + if browser is not None: + close_standalone(browser) + if proxy_bridge is not None: + try: + proxy_bridge.stop() + except Exception: + pass + raise + +def close_standalone(browser: Any) -> None: + if browser is None: + return + _unregister_mint_browser(browser) + bridge = getattr(browser, "_cpa_proxy_bridge", None) + try: + browser.quit() + except Exception: + pass + if bridge is not None: + try: + bridge.stop() + except Exception: + pass + +def _register_mint_browser(browser: Any) -> None: + if browser is None: + return + with _mint_registry_lock: + _mint_registry.add(browser) + +def _unregister_mint_browser(browser: Any) -> None: + if browser is None: + return + with _mint_registry_lock: + _mint_registry.discard(browser) + +def _mint_tls_get(): + state = getattr(_mint_tls, "state", None) + if state is None: + state = {"browser": None, "page": None, "served": 0, "proxy": None, "headless": None} + _mint_tls.state = state + return state + +def clear_page_session(page: Any, browser: Optional[Any] = None, log: Optional[LogFn] = None) -> None: + logger = log or _noop_log + try: + if page is not None: + try: + page.get("about:blank") + except Exception: + pass + for javascript in ( + "try{localStorage.clear()}catch(e){}", + "try{sessionStorage.clear()}catch(e){}", + ): + try: + page.run_js(javascript) + except Exception: + pass + for target in (page, browser): + if target is None: + continue + try: + target.set.cookies.clear() # type: ignore[attr-defined] + logger("mint session cookies cleared") + break + except Exception: + try: + cookies = target.cookies() + if isinstance(cookies, list): + for cookie in cookies: + try: + target.set.cookies.remove(cookie) # type: ignore[attr-defined] + except Exception: + pass + except Exception: + pass + except Exception as exc: + logger("clear_page_session: %s" % exc) + +def normalize_cookies(cookies: Any): + output = [] + if not cookies: + return output + if isinstance(cookies, dict): + for name, value in cookies.items(): + if name and value is not None: + output.append({"name": str(name), "value": str(value), "domain": ".x.ai", "path": "/"}) + cookies = output + output = [] + if not isinstance(cookies, (list, tuple)): + return output + for cookie in cookies: + if not isinstance(cookie, dict): + continue + name = cookie.get("name") or cookie.get("Name") + value = cookie.get("value") or cookie.get("Value") + if not name or value is None: + continue + domain = str(cookie.get("domain") or cookie.get("Domain") or ".x.ai") + path = str(cookie.get("path") or cookie.get("Path") or "/") + item = {"name": str(name), "value": str(value), "domain": domain, "path": path} + for source, target in ( + ("expiry", "expiry"), + ("expires", "expiry"), + ("secure", "secure"), + ("httpOnly", "httpOnly"), + ("sameSite", "sameSite"), + ): + if source in cookie and cookie[source] is not None: + item[target] = cookie[source] + output.append(item) + sso_names = {"sso", "sso-rw", "cf_clearance", "sso_jwt", "__cf_bm"} + extras = [] + seen = {(item["name"], item["domain"], item["path"]) for item in output} + for item in list(output): + name = item["name"] + if name not in sso_names and not name.startswith("sso"): + continue + for domain in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai"): + key = (name, domain, item["path"]) + if key in seen: + continue + clone = dict(item) + clone["domain"] = domain + extras.append(clone) + seen.add(key) + output.extend(extras) + return output + +def inject_cookies(page: Any, cookies: Any, log: Optional[LogFn] = None) -> int: + logger = log or _noop_log + items = normalize_cookies(cookies) + if not items or page is None: + return 0 + for url in ("https://accounts.x.ai/", "https://auth.x.ai/", "https://grok.com/"): + try: + page.get(url) + _sleep(0.4) + except Exception: + continue + count = 0 + for target_name, target in (("page", page), ("browser", getattr(page, "browser", None))): + if target is None: + continue + try: + target.set.cookies(items) # type: ignore[attr-defined] + count = len(items) + logger("injected cookies bulk via %s=%s" % (target_name, count)) + break + except Exception as exc: + logger("bulk set via %s failed: %s" % (target_name, exc)) + if count == 0: + for item in items: + ok = False + for target in (page, getattr(page, "browser", None)): + if target is None: + continue + try: + target.set.cookies(item) # type: ignore[attr-defined] + ok = True + break + except Exception: + continue + if ok: + count += 1 + logger("injected cookies one-by-one=%s/%s" % (count, len(items))) + try: + javascript_items = [ + cookie + for cookie in items + if not bool(cookie.get("httpOnly")) and str(cookie.get("name") or "").startswith("sso") + ] + if javascript_items: + page.run_js( + """ + const items = arguments[0] || []; + for (const c of items) { + let cookie = `${c.name}=${c.value}; path=${c.path || '/'}; domain=${c.domain || '.x.ai'}`; + if (c.secure !== false) cookie += '; Secure'; + document.cookie = cookie; + } + return document.cookie; + """, + javascript_items, + ) + logger("injected non-httpOnly sso cookies via document.cookie") + except Exception as exc: + logger("document.cookie injection failed: %s" % exc) + return count + +def acquire_mint_browser(proxy: Optional[str] = None, headless: bool = False, reuse: bool = True, recycle_every: int = 15, log: Optional[LogFn] = None): + logger = log or _noop_log + state = _mint_tls_get() + if not reuse: + browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) + return browser, page, True + proxy_key = str(proxy or "") + if state["browser"] is None: + browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) + state.update({"browser": browser, "page": page, "served": 0, "proxy": proxy_key, "headless": bool(headless)}) + return browser, page, False + if state["proxy"] != proxy_key or bool(state["headless"]) != bool(headless): + try: + close_standalone(state["browser"]) + except Exception: + pass + browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) + state.update({"browser": browser, "page": page, "served": 0, "proxy": proxy_key, "headless": bool(headless)}) + return browser, page, False + if recycle_every and state["served"] and state["served"] % max(int(recycle_every), 1) == 0: + try: + close_standalone(state["browser"]) + except Exception: + pass + browser, page = create_standalone_page(proxy=proxy, headless=headless, log=logger) + state.update({"browser": browser, "page": page, "served": 0, "proxy": proxy_key, "headless": bool(headless)}) + return browser, page, False + clear_page_session(state["page"], browser=state["browser"], log=logger) + return state["browser"], state["page"], False + +def release_mint_browser(owned: bool, success: bool, log: Optional[LogFn] = None) -> None: + logger = log or _noop_log + if owned: + return + state = _mint_tls_get() + if success: + state["served"] = int(state.get("served", 0) or 0) + 1 + logger("mint browser served=%s" % state["served"]) + +def shutdown_mint_browsers() -> None: + state = _mint_tls_get() + with _mint_registry_lock: + browsers = list(_mint_registry) + for browser in browsers: + try: + close_standalone(browser) + except Exception: + pass + state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None}) + diff --git a/grok_register_ttk.py b/grok_register_ttk.py index 277f4b8..ab8a5e9 100755 --- a/grok_register_ttk.py +++ b/grok_register_ttk.py @@ -45,8 +45,20 @@ from DrissionPage import Chromium, ChromiumOptions from DrissionPage.errors import PageDisconnectedError from curl_cffi import requests +import functools +import types +import app_config as _app_config +import account_outputs as _account_outputs +import browser_runtime as _browser_runtime +import mail_service as _mail_service +import registration_browser as _registration_browser +from app_config import ( + CONFIG_FILE, DEFAULT_CONFIG, ConfigError, config, load_config, save_config, + validate_config, validate_config_structure, validate_run_requirements, +) + + -CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") MEMORY_CLEANUP_INTERVAL = 5 UI_BG = "#242424" @@ -57,52 +69,7 @@ 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): @@ -113,8 +80,6 @@ class AccountRetryNeeded(Exception): pass -class ConfigError(RuntimeError): - pass class RemoteTokenCompatibilityError(RuntimeError): @@ -134,124 +99,16 @@ def log_exception(context, exc, log_callback=None): 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(): @@ -350,786 +207,178 @@ EXTENSION_PATH = os.path.abspath( ) -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", - "无法连接到代理服务器", - "代理服务器", - ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +def _make_compat_proxy(module, name, binder=None): + target = getattr(module, name) + @functools.wraps(target) + def proxy(*args, **kwargs): + if binder is not None: + binder() + return getattr(module, name)(*args, **kwargs) + return proxy + + +def _bind_browser_runtime(): + _browser_runtime.configure_runtime(config, EXTENSION_PATH) + + +def _bind_account_outputs(): + _account_outputs.configure_token_runtime( + config, http_get, http_post, log_exception, + compatibility_error=RemoteTokenCompatibilityError, + request_error=RemoteTokenRequestError, ) -class _ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): - allow_reuse_address = True - daemon_threads = True +def _bind_mail_service(): + _mail_service.bind_runtime(globals()) + _current = globals().get("generate_username") + _standard = _MAIL_COMPAT_PROXIES.get("generate_username") + if _current is not None and _current is not _standard: + _mail_service.generate_username = _current + elif _standard is not None: + _mail_service.generate_username = _MAIL_ORIGINALS["generate_username"] -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 _bind_registration_browser(): + _registration_browser.bind_runtime(globals()) -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: +LocalAuthProxyBridge = _browser_runtime.LocalAuthProxyBridge +for _name in ['get_configured_proxy', 'get_proxies', '_parse_proxy_url', '_safe_proxy_port', '_proxy_has_auth', '_strip_proxy_auth', '_proxy_endpoint_terms', 'is_proxy_connection_error', 'page_has_proxy_error', '_ReusableThreadingTCPServer', '_proxy_recv_until_headers', '_proxy_relay', '_LocalAuthProxyBridgeHandler', 'LocalAuthProxyBridge', 'prepare_browser_proxy', 'apply_browser_proxy_option', 'create_browser_options', '_build_request_kwargs', 'http_get', 'http_post']: + if _name.startswith("_") and _name in {"_ReusableThreadingTCPServer", "_LocalAuthProxyBridgeHandler", "_proxy_recv_until_headers", "_proxy_relay"}: + continue + if _name != "LocalAuthProxyBridge": + globals()[_name] = _make_compat_proxy(_browser_runtime, _name, _bind_browser_runtime) +for _name in ['resolve_grok2api_local_token_file', '_normalize_sso_token', 'add_token_to_grok2api_local_pool', 'get_grok2api_remote_api_bases', 'add_token_to_grok2api_remote_pool', 'add_token_to_grok2api_pools']: + globals()[_name] = _make_compat_proxy(_account_outputs, _name, _bind_account_outputs) +_MAIL_ORIGINALS = dict((name, getattr(_mail_service, name)) for name in ['_pick_list_payload', 'cloudflare_apply_auth_params', 'cloudflare_build_headers', 'cloudflare_create_account', 'cloudflare_create_temp_address', 'cloudflare_get_domains', 'cloudflare_get_message_detail', 'cloudflare_get_messages', 'cloudflare_get_oai_code', 'cloudflare_get_token', 'cloudflare_is_admin_create_path', 'cloudflare_next_default_domain', 'cloudmail_get_email_and_token', 'cloudmail_get_messages', 'cloudmail_get_oai_code', 'cloudmail_next_domain', 'create_account', 'duckmail_get_oai_code', 'extract_verification_code', 'generate_username', 'get_cloudflare_api_base', 'get_cloudflare_api_key', 'get_cloudflare_auth_mode', 'get_cloudflare_path', 'get_cloudmail_api_base', 'get_cloudmail_path', 'get_cloudmail_public_token', 'get_domains', 'get_duckmail_api_key', 'get_email_and_token', 'get_email_provider', 'get_message_detail', 'get_messages', 'get_oai_code', 'get_token', 'get_user_agent', 'get_yyds_api_key', 'get_yyds_jwt', 'pick_domain', 'yyds_create_account', 'yyds_generate_username', 'yyds_get_domains', 'yyds_get_email_and_token', 'yyds_get_message_detail', 'yyds_get_messages', 'yyds_get_oai_code', 'yyds_get_token', 'yyds_pick_domain']) +_MAIL_COMPAT_PROXIES = dict() +for _name in ['_pick_list_payload', 'cloudflare_apply_auth_params', 'cloudflare_build_headers', 'cloudflare_create_account', 'cloudflare_create_temp_address', 'cloudflare_get_domains', 'cloudflare_get_message_detail', 'cloudflare_get_messages', 'cloudflare_get_oai_code', 'cloudflare_get_token', 'cloudflare_is_admin_create_path', 'cloudflare_next_default_domain', 'cloudmail_get_email_and_token', 'cloudmail_get_messages', 'cloudmail_get_oai_code', 'cloudmail_next_domain', 'create_account', 'duckmail_get_oai_code', 'extract_verification_code', 'generate_username', 'get_cloudflare_api_base', 'get_cloudflare_api_key', 'get_cloudflare_auth_mode', 'get_cloudflare_path', 'get_cloudmail_api_base', 'get_cloudmail_path', 'get_cloudmail_public_token', 'get_domains', 'get_duckmail_api_key', 'get_email_and_token', 'get_email_provider', 'get_message_detail', 'get_messages', 'get_oai_code', 'get_token', 'get_user_agent', 'get_yyds_api_key', 'get_yyds_jwt', 'pick_domain', 'yyds_create_account', 'yyds_generate_username', 'yyds_get_domains', 'yyds_get_email_and_token', 'yyds_get_message_detail', 'yyds_get_messages', 'yyds_get_oai_code', 'yyds_get_token', 'yyds_pick_domain']: + _proxy = _make_compat_proxy(_mail_service, _name, _bind_mail_service) + _MAIL_COMPAT_PROXIES[_name] = _proxy + globals()[_name] = _proxy +for _name in ['generate_random_birthdate', 'response_preview', 'is_cloudflare_block_response', 'set_birth_date', 'set_tos_accepted', 'encode_grpc_nsfw_settings', 'update_nsfw_settings', 'enable_nsfw_for_token', 'stop_browser_proxy_bridge', 'start_browser', 'stop_browser', 'restart_browser', 'cleanup_runtime_memory', 'refresh_active_page', 'click_email_signup_button', 'open_signup_page', 'has_profile_form', 'fill_email_and_submit', 'fill_code_and_submit', 'getTurnstileToken', 'build_profile', 'fill_profile_and_submit', 'wait_for_sso_cookie']: + globals()[_name] = _make_compat_proxy(_registration_browser, _name, _bind_registration_browser) + + +def __getattr__(name): + if name in {"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance"}: + return getattr(_registration_browser, name) + if name in {"_cf_domain_index", "_cloudmail_domain_index"}: + return getattr(_mail_service, name) + raise AttributeError(name) + + +class _CompatibilityModule(types.ModuleType): + def __setattr__(self, name, value): + if name == "config": + if value is not _app_config.config: + if not isinstance(value, dict): + raise TypeError("config must be a dict") + _app_config.config.clear() + _app_config.config.update(value) + value = _app_config.config + elif name in {"_cf_domain_index", "_cloudmail_domain_index"}: + setattr(_mail_service, name, int(value)) + self.__dict__.pop(name, None) return - for sock in readable: - data = sock.recv(65536) - if not data: - return - peer = right if sock is left else left - peer.sendall(data) + super().__setattr__(name, value) -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 +sys.modules[__name__].__class__ = _CompatibilityModule def raise_if_cancelled(cancel_callback=None): @@ -1147,873 +396,85 @@ def sleep_with_cancel(seconds, cancel_callback=None): 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): @@ -2117,1119 +578,32 @@ def tk_option_menu(parent, variable, values, width=12): 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)}" - ) @@ -3244,7 +618,7 @@ def maybe_export_cpa_xai_after_success(email, password, sso="", log_callback=Non return {"ok": False, "error": str(exc)} current_page = None try: - current_page = page + current_page = _registration_browser.page except Exception: current_page = None try: @@ -3304,7 +678,7 @@ def run_registration_common(count, log_callback, cancel_callback, accounts_outpu 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, + browser_missing=lambda: _registration_browser.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), diff --git a/mail_service.py b/mail_service.py new file mode 100644 index 0000000..a24fcb4 --- /dev/null +++ b/mail_service.py @@ -0,0 +1,988 @@ +"""Temporary-mail providers shared by GUI, CLI, and debug tooling.""" +import re +import secrets +import string +import time +from typing import Any, Dict, List, Optional, Tuple + +from curl_cffi import requests + +DUCKMAIL_API_BASE = "https://api.duckmail.sbs" + +YYDS_API_BASE = "https://maliapi.215.im/v1" + + +config = {} +_cf_domain_index = 0 +_cloudmail_domain_index = 0 +_OWN_NAMES = {'cloudmail_get_email_and_token', 'get_messages', 'cloudflare_get_messages', 'get_yyds_api_key', 'yyds_generate_username', 'yyds_get_domains', 'yyds_get_email_and_token', 'yyds_get_oai_code', 'get_email_provider', 'cloudflare_get_domains', 'extract_verification_code', 'get_cloudflare_api_base', 'cloudflare_apply_auth_params', 'duckmail_get_oai_code', 'create_account', 'get_yyds_jwt', 'get_message_detail', 'yyds_create_account', 'get_duckmail_api_key', 'get_cloudflare_path', 'cloudflare_create_account', 'cloudflare_get_token', 'cloudflare_get_oai_code', 'get_cloudmail_public_token', 'generate_username', 'yyds_get_message_detail', 'cloudflare_next_default_domain', 'yyds_get_messages', 'yyds_get_token', 'get_domains', 'get_token', 'cloudflare_create_temp_address', 'get_cloudflare_api_key', 'get_cloudmail_path', 'get_cloudmail_api_base', 'cloudmail_get_oai_code', 'cloudflare_build_headers', 'cloudflare_is_admin_create_path', 'cloudmail_next_domain', 'cloudflare_get_message_detail', 'cloudmail_get_messages', 'get_user_agent', 'yyds_pick_domain', '_pick_list_payload', 'get_email_and_token', 'get_oai_code', 'get_cloudflare_auth_mode', 'pick_domain'} + + +def bind_runtime(namespace): + global config + config = namespace.get("config", config) + for name, value in namespace.items(): + if name.startswith("__") or name in _OWN_NAMES or name in {"config", "_cf_domain_index", "_cloudmail_domain_index"}: + continue + globals()[name] = value + + +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_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_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_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_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 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_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}") + +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_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 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_is_admin_create_path(path): + """判断当前创建邮箱路径是否为 cloudflare_temp_email 管理员创建接口。""" + return str(path or "").rstrip("/").lower() == "/admin/new_address" + +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 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 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 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 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 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 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 generate_username(length=10): + chars = string.ascii_lowercase + string.digits + return "".join(secrets.choice(chars) for _ in range(length)) + +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 get_cloudmail_api_base(): + return str(config.get("cloudmail_api_base", "") or "").strip().rstrip("/") + +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 get_cloudmail_public_token(): + return str(config.get("cloudmail_public_token", "") or "").strip() + +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 get_duckmail_api_key(): + return config.get("duckmail_api_key", "") + +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_email_provider(): + return config.get("email_provider", "duckmail") + +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 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_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 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_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 get_yyds_api_key(): + return config.get("yyds_api_key", "") + +def get_yyds_jwt(): + return config.get("yyds_jwt", "") + +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 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_generate_username(length=10): + chars = string.ascii_lowercase + string.digits + return "".join(secrets.choice(chars) for _ in range(length)) + +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_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_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_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_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 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_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 无已验证域名可用") + + + +class CloudflareMailClient: + """Standalone Cloudflare mail client used by the debug CLI.""" + def __init__(self, api_base, auth_mode="none", api_key="", create_path="/api/new_address", timeout=20): + self.api_base = str(api_base or "").rstrip("/") + self.auth_mode = str(auth_mode or "none").lower() + self.api_key = str(api_key or "") + self.create_path = self.normalize_path(create_path, "/api/new_address") + self.timeout = int(timeout) + + @staticmethod + def normalize_path(path, default_path): + raw = (path or default_path).strip() or default_path + return raw if raw.startswith("/") else "/" + raw + + def build_auth_headers(self, content_type=False): + headers = {"Content-Type": "application/json"} if content_type else {} + if not self.api_key: + return headers + if self.auth_mode == "x-admin-auth": + headers["x-admin-auth"] = self.api_key + elif self.auth_mode == "x-api-key": + headers["X-API-Key"] = self.api_key + elif self.auth_mode == "bearer": + headers["Authorization"] = "Bearer " + self.api_key + return headers + + @staticmethod + def json_or_text(response): + try: + return response.json(), "" + except Exception: + return None, str(getattr(response, "text", "") or "")[:400] + + def create_address(self, domain="", name=""): + is_admin = self.create_path.rstrip("/").lower() == "/admin/new_address" + payload = {} + headers = {"Content-Type": "application/json"} + if is_admin: + payload = {"name": name.strip() if str(name).strip() else generate_username(), "enablePrefix": True} + if str(domain).strip(): + payload["domain"] = str(domain).strip() + headers = self.build_auth_headers(content_type=True) + elif str(domain).strip(): + payload["domain"] = str(domain).strip() + response = requests.post(self.api_base + self.create_path, json=payload, headers=headers, timeout=self.timeout) + response.raise_for_status() + data, raw = self.json_or_text(response) + if not data: + raise RuntimeError("%s 非JSON: %s" % (self.create_path, raw)) + address = str(data.get("address", "")).strip() + jwt = str(data.get("jwt", "")).strip() + if not address or not jwt: + raise RuntimeError("%s 缺少 address/jwt: %r" % (self.create_path, data)) + return address, jwt + + def fetch_box(self, jwt, path, params): + response = requests.get( + self.api_base + path, + params=params, + headers={"Authorization": "Bearer " + str(jwt)}, + timeout=self.timeout, + ) + if response.status_code >= 400: + return [] + data, _ = self.json_or_text(response) + return _pick_list_payload(data) + + def probe_all_boxes(self, jwt): + probes = [ + ("/api/mails", {"limit": 20, "offset": 0}), + ("/api/sendbox", {"limit": 20, "offset": 0}), + ("/api/mails", {"limit": 20, "offset": 0, "box": "trash"}), + ("/api/mails", {"limit": 20, "offset": 0, "folder": "trash"}), + ("/api/mails", {"limit": 20, "offset": 0, "deleted": "1"}), + ("/api/mails", {"limit": 20, "offset": 0, "status": "deleted"}), + ] + return [("%s?%s" % (path, params), self.fetch_box(jwt, path, params)) for path, params in probes] + + def get_detail(self, jwt, mail_id): + for path in ("/api/mail/%s" % mail_id, "/api/mails/%s" % mail_id): + try: + response = requests.get( + self.api_base + path, + headers={"Authorization": "Bearer " + str(jwt)}, + timeout=self.timeout, + ) + if response.status_code >= 400: + continue + data, _ = self.json_or_text(response) + if isinstance(data, dict): + return data + except Exception: + continue + return {} + + @staticmethod + def flatten_mail_text(item, detail): + subject = str(item.get("subject") or detail.get("subject") or "") + parts = [] + for source in (item, detail): + for key in ("text", "raw", "content", "intro", "body", "snippet"): + value = source.get(key) + if isinstance(value, str) and value.strip(): + parts.append(value) + html_value = source.get("html") + if isinstance(html_value, str): + html_value = [html_value] + if isinstance(html_value, list): + parts.extend(re.sub(r"<[^>]+>", " ", item) for item in html_value if isinstance(item, str)) + return subject, "\n".join(parts) diff --git a/refactor_inventory.json b/refactor_inventory.json deleted file mode 100644 index 82d8f8f..0000000 --- a/refactor_inventory.json +++ /dev/null @@ -1,3423 +0,0 @@ -{ - "grok_register_ttk.py": { - "lines": 3835, - "definitions": [ - { - "kind": "ClassDef", - "name": "RegistrationCancelled", - "start": 108, - "end": 109, - "free_names": [ - "Exception" - ] - }, - { - "kind": "ClassDef", - "name": "AccountRetryNeeded", - "start": 112, - "end": 113, - "free_names": [ - "Exception" - ] - }, - { - "kind": "ClassDef", - "name": "ConfigError", - "start": 116, - "end": 117, - "free_names": [ - "RuntimeError" - ] - }, - { - "kind": "ClassDef", - "name": "RemoteTokenCompatibilityError", - "start": 120, - "end": 121, - "free_names": [ - "RuntimeError" - ] - }, - { - "kind": "ClassDef", - "name": "RemoteTokenRequestError", - "start": 124, - "end": 125, - "free_names": [ - "RuntimeError" - ] - }, - { - "kind": "FunctionDef", - "name": "log_exception", - "start": 128, - "end": 134, - "free_names": [ - "context", - "exc", - "log_callback", - "print", - "sys" - ] - }, - { - "kind": "FunctionDef", - "name": "_require_bool", - "start": 137, - "end": 141, - "free_names": [ - "ConfigError", - "bool", - "cfg", - "key", - "type" - ] - }, - { - "kind": "FunctionDef", - "name": "_require_int", - "start": 144, - "end": 150, - "free_names": [ - "ConfigError", - "cfg", - "int", - "key", - "maximum", - "minimum", - "type" - ] - }, - { - "kind": "FunctionDef", - "name": "_require_string", - "start": 153, - "end": 162, - "free_names": [ - "ConfigError", - "cfg", - "isinstance", - "key", - "os", - "path", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "validate_config_structure", - "start": 165, - "end": 223, - "free_names": [ - "ConfigError", - "DEFAULT_CONFIG", - "_require_bool", - "_require_int", - "_require_string", - "dict", - "isinstance", - "os", - "raw", - "sorted", - "str", - "tuple", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "validate_run_requirements", - "start": 226, - "end": 249, - "free_names": [ - "ConfigError", - "validate_config_structure" - ] - }, - { - "kind": "FunctionDef", - "name": "validate_config", - "start": 252, - "end": 254, - "free_names": [ - "raw", - "validate_run_requirements" - ] - }, - { - "kind": "FunctionDef", - "name": "load_config", - "start": 257, - "end": 270, - "free_names": [ - "CONFIG_FILE", - "ConfigError", - "DEFAULT_CONFIG", - "Exception", - "exc", - "json", - "open", - "os", - "validate_config_structure" - ] - }, - { - "kind": "FunctionDef", - "name": "save_config", - "start": 273, - "end": 310, - "free_names": [ - "CONFIG_FILE", - "ConfigError", - "Exception", - "exc", - "json", - "os", - "tempfile", - "validate_config_structure" - ] - }, - { - "kind": "FunctionDef", - "name": "ensure_stable_python_runtime", - "start": 313, - "end": 335, - "free_names": [ - "__file__", - "os", - "print", - "sys" - ] - }, - { - "kind": "FunctionDef", - "name": "warn_runtime_compatibility", - "start": 338, - "end": 342, - "free_names": [ - "print", - "sys" - ] - }, - { - "kind": "FunctionDef", - "name": "get_configured_proxy", - "start": 356, - "end": 357, - "free_names": [ - "config", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_proxies", - "start": 360, - "end": 364, - "free_names": [ - "get_configured_proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "_parse_proxy_url", - "start": 367, - "end": 376, - "free_names": [ - "Exception", - "proxy", - "str", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "_safe_proxy_port", - "start": 379, - "end": 383, - "free_names": [ - "Exception", - "parsed" - ] - }, - { - "kind": "FunctionDef", - "name": "_proxy_has_auth", - "start": 386, - "end": 388, - "free_names": [ - "_parse_proxy_url", - "bool", - "proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "_strip_proxy_auth", - "start": 391, - "end": 404, - "free_names": [ - "_parse_proxy_url", - "_safe_proxy_port", - "proxy", - "str", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "_proxy_endpoint_terms", - "start": 407, - "end": 416, - "free_names": [ - "_parse_proxy_url", - "_safe_proxy_port", - "get_configured_proxy", - "proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "is_proxy_connection_error", - "start": 419, - "end": 440, - "free_names": [ - "_proxy_endpoint_terms", - "any", - "exc", - "get_configured_proxy", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "page_has_proxy_error", - "start": 443, - "end": 462, - "free_names": [ - "Exception", - "any", - "getattr", - "page_obj", - "str" - ] - }, - { - "kind": "ClassDef", - "name": "_ReusableThreadingTCPServer", - "start": 465, - "end": 467, - "free_names": [ - "socketserver" - ] - }, - { - "kind": "FunctionDef", - "name": "_proxy_recv_until_headers", - "start": 470, - "end": 478, - "free_names": [ - "len", - "limit", - "sock", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "_proxy_relay", - "start": 481, - "end": 494, - "free_names": [ - "left", - "right", - "select", - "timeout" - ] - }, - { - "kind": "ClassDef", - "name": "_LocalAuthProxyBridgeHandler", - "start": 497, - "end": 531, - "free_names": [ - "Exception", - "_proxy_recv_until_headers", - "_proxy_relay", - "self", - "socketserver" - ] - }, - { - "kind": "ClassDef", - "name": "LocalAuthProxyBridge", - "start": 534, - "end": 589, - "free_names": [ - "Exception", - "ValueError", - "_LocalAuthProxyBridgeHandler", - "_ReusableThreadingTCPServer", - "_parse_proxy_url", - "_safe_proxy_port", - "base64", - "data", - "proxy_url", - "self", - "socket", - "ssl", - "threading", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "stop_browser_proxy_bridge", - "start": 592, - "end": 599, - "free_names": [ - "Exception" - ] - }, - { - "kind": "FunctionDef", - "name": "prepare_browser_proxy", - "start": 602, - "end": 619, - "free_names": [ - "LocalAuthProxyBridge", - "_parse_proxy_url", - "_proxy_has_auth", - "_strip_proxy_auth", - "get_configured_proxy", - "log_callback", - "use_proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "get_duckmail_api_key", - "start": 622, - "end": 623, - "free_names": [ - "config" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudflare_api_base", - "start": 626, - "end": 627, - "free_names": [ - "config", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudflare_api_key", - "start": 630, - "end": 631, - "free_names": [ - "config" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudflare_auth_mode", - "start": 634, - "end": 635, - "free_names": [ - "config", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudflare_path", - "start": 638, - "end": 642, - "free_names": [ - "config", - "default_path", - "key", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_build_headers", - "start": 645, - "end": 656, - "free_names": [ - "content_type", - "get_cloudflare_api_key", - "get_cloudflare_auth_mode" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_apply_auth_params", - "start": 659, - "end": 665, - "free_names": [ - "dict", - "get_cloudflare_api_key", - "get_cloudflare_auth_mode", - "params" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_next_default_domain", - "start": 668, - "end": 676, - "free_names": [ - "config", - "len", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_is_admin_create_path", - "start": 679, - "end": 681, - "free_names": [ - "path", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_pick_list_payload", - "start": 684, - "end": 700, - "free_names": [ - "data", - "dict", - "isinstance", - "list" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_create_temp_address", - "start": 703, - "end": 729, - "free_names": [ - "Exception", - "api_base", - "cloudflare_build_headers", - "cloudflare_is_admin_create_path", - "cloudflare_next_default_domain", - "generate_username", - "get_cloudflare_path", - "http_post" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudmail_api_base", - "start": 732, - "end": 733, - "free_names": [ - "config", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudmail_public_token", - "start": 736, - "end": 737, - "free_names": [ - "config", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_cloudmail_path", - "start": 740, - "end": 745, - "free_names": [ - "config", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudmail_next_domain", - "start": 748, - "end": 760, - "free_names": [ - "config", - "len", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudmail_get_email_and_token", - "start": 763, - "end": 774, - "free_names": [ - "Exception", - "cloudmail_next_domain", - "generate_username", - "get_cloudmail_api_base", - "get_cloudmail_public_token" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudmail_get_messages", - "start": 777, - "end": 816, - "free_names": [ - "Exception", - "_pick_list_payload", - "address", - "dict", - "get_cloudmail_api_base", - "get_cloudmail_path", - "get_cloudmail_public_token", - "http_post", - "isinstance", - "list" - ] - }, - { - "kind": "FunctionDef", - "name": "get_user_agent", - "start": 819, - "end": 823, - "free_names": [ - "config" - ] - }, - { - "kind": "FunctionDef", - "name": "resolve_grok2api_local_token_file", - "start": 826, - "end": 830, - "free_names": [ - "__file__", - "config", - "os", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_normalize_sso_token", - "start": 833, - "end": 837, - "free_names": [ - "raw_token", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "add_token_to_grok2api_local_pool", - "start": 840, - "end": 929, - "free_names": [ - "Exception", - "FileLock", - "RuntimeError", - "_normalize_sso_token", - "config", - "dict", - "email", - "exc", - "int", - "isinstance", - "json", - "list", - "log_callback", - "open", - "os", - "raw_token", - "resolve_grok2api_local_token_file", - "set", - "str", - "tempfile", - "time" - ] - }, - { - "kind": "FunctionDef", - "name": "get_grok2api_remote_api_bases", - "start": 932, - "end": 958, - "free_names": [ - "base", - "set", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "add_token_to_grok2api_remote_pool", - "start": 961, - "end": 1050, - "free_names": [ - "Exception", - "RemoteTokenCompatibilityError", - "RemoteTokenRequestError", - "_normalize_sso_token", - "bool", - "config", - "dict", - "email", - "exc", - "get_grok2api_remote_api_bases", - "getattr", - "http_get", - "http_post", - "int", - "isinstance", - "list", - "log_callback", - "raw_token", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "add_token_to_grok2api_pools", - "start": 1053, - "end": 1070, - "free_names": [ - "Exception", - "add_token_to_grok2api_local_pool", - "add_token_to_grok2api_remote_pool", - "bool", - "config", - "email", - "exc", - "log_callback", - "log_exception", - "raw_token" - ] - }, - { - "kind": "FunctionDef", - "name": "apply_browser_proxy_option", - "start": 1073, - "end": 1087, - "free_names": [ - "AttributeError", - "Exception", - "TypeError", - "hasattr", - "options", - "proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "create_browser_options", - "start": 1090, - "end": 1097, - "free_names": [ - "ChromiumOptions", - "EXTENSION_PATH", - "apply_browser_proxy_option", - "browser_proxy", - "os" - ] - }, - { - "kind": "FunctionDef", - "name": "_build_request_kwargs", - "start": 1100, - "end": 1108, - "free_names": [ - "dict", - "get_proxies", - "kwargs" - ] - }, - { - "kind": "FunctionDef", - "name": "http_get", - "start": 1111, - "end": 1120, - "free_names": [ - "Exception", - "_build_request_kwargs", - "dict", - "exc", - "is_proxy_connection_error", - "kwargs", - "requests", - "url" - ] - }, - { - "kind": "FunctionDef", - "name": "http_post", - "start": 1123, - "end": 1132, - "free_names": [ - "Exception", - "_build_request_kwargs", - "dict", - "exc", - "is_proxy_connection_error", - "kwargs", - "requests", - "url" - ] - }, - { - "kind": "FunctionDef", - "name": "raise_if_cancelled", - "start": 1135, - "end": 1137, - "free_names": [ - "RegistrationCancelled", - "cancel_callback" - ] - }, - { - "kind": "FunctionDef", - "name": "sleep_with_cancel", - "start": 1140, - "end": 1147, - "free_names": [ - "cancel_callback", - "max", - "min", - "raise_if_cancelled", - "seconds", - "time" - ] - }, - { - "kind": "FunctionDef", - "name": "get_domains", - "start": 1150, - "end": 1157, - "free_names": [ - "DUCKMAIL_API_BASE", - "api_key", - "get_duckmail_api_key", - "http_get" - ] - }, - { - "kind": "FunctionDef", - "name": "create_account", - "start": 1160, - "end": 1168, - "free_names": [ - "DUCKMAIL_API_BASE", - "address", - "api_key", - "expires_in", - "get_duckmail_api_key", - "http_post", - "password" - ] - }, - { - "kind": "FunctionDef", - "name": "get_token", - "start": 1171, - "end": 1175, - "free_names": [ - "DUCKMAIL_API_BASE", - "address", - "http_post", - "password" - ] - }, - { - "kind": "FunctionDef", - "name": "get_messages", - "start": 1178, - "end": 1182, - "free_names": [ - "DUCKMAIL_API_BASE", - "http_get", - "token" - ] - }, - { - "kind": "FunctionDef", - "name": "get_message_detail", - "start": 1185, - "end": 1189, - "free_names": [ - "DUCKMAIL_API_BASE", - "http_get", - "message_id", - "token" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_get_domains", - "start": 1192, - "end": 1202, - "free_names": [ - "_pick_list_payload", - "api_base", - "api_key", - "cloudflare_apply_auth_params", - "cloudflare_build_headers", - "get_cloudflare_path", - "http_get" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_create_account", - "start": 1205, - "end": 1216, - "free_names": [ - "address", - "api_base", - "api_key", - "cloudflare_apply_auth_params", - "cloudflare_build_headers", - "expires_in", - "get_cloudflare_path", - "http_post", - "password" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_get_token", - "start": 1219, - "end": 1239, - "free_names": [ - "address", - "api_base", - "api_key", - "cloudflare_apply_auth_params", - "cloudflare_build_headers", - "dict", - "get_cloudflare_path", - "http_post", - "isinstance", - "password" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_get_messages", - "start": 1242, - "end": 1253, - "free_names": [ - "Exception", - "_pick_list_payload", - "api_base", - "cloudflare_apply_auth_params", - "get_cloudflare_path", - "http_get", - "token" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_get_message_detail", - "start": 1256, - "end": 1278, - "free_names": [ - "Exception", - "api_base", - "cloudflare_apply_auth_params", - "dict", - "exc", - "get_cloudflare_path", - "http_get", - "isinstance", - "message_id", - "token" - ] - }, - { - "kind": "FunctionDef", - "name": "get_yyds_api_key", - "start": 1284, - "end": 1285, - "free_names": [ - "config" - ] - }, - { - "kind": "FunctionDef", - "name": "get_yyds_jwt", - "start": 1288, - "end": 1289, - "free_names": [ - "config" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_get_domains", - "start": 1292, - "end": 1303, - "free_names": [ - "YYDS_API_BASE", - "api_key", - "get_yyds_api_key", - "get_yyds_jwt", - "http_get", - "jwt" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_create_account", - "start": 1306, - "end": 1326, - "free_names": [ - "Exception", - "YYDS_API_BASE", - "address", - "api_key", - "domain", - "get_yyds_api_key", - "get_yyds_jwt", - "http_post", - "jwt" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_get_token", - "start": 1329, - "end": 1344, - "free_names": [ - "Exception", - "YYDS_API_BASE", - "address", - "api_key", - "get_yyds_api_key", - "get_yyds_jwt", - "http_post", - "jwt" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_get_messages", - "start": 1347, - "end": 1364, - "free_names": [ - "YYDS_API_BASE", - "address", - "api_key", - "get_yyds_api_key", - "get_yyds_jwt", - "http_get", - "jwt", - "token" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_get_message_detail", - "start": 1367, - "end": 1380, - "free_names": [ - "Exception", - "YYDS_API_BASE", - "api_key", - "get_yyds_api_key", - "get_yyds_jwt", - "http_get", - "jwt", - "message_id", - "token" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_generate_username", - "start": 1383, - "end": 1385, - "free_names": [ - "length", - "range", - "secrets", - "string" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_pick_domain", - "start": 1388, - "end": 1401, - "free_names": [ - "Exception", - "api_key", - "jwt", - "yyds_get_domains" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_get_email_and_token", - "start": 1404, - "end": 1421, - "free_names": [ - "Exception", - "api_key", - "get_yyds_api_key", - "get_yyds_jwt", - "jwt", - "print", - "yyds_create_account", - "yyds_generate_username", - "yyds_get_token", - "yyds_pick_domain" - ] - }, - { - "kind": "FunctionDef", - "name": "yyds_get_oai_code", - "start": 1424, - "end": 1475, - "free_names": [ - "Exception", - "address", - "cancel_callback", - "exc", - "extract_verification_code", - "jwt", - "log_callback", - "poll_interval", - "raise_if_cancelled", - "re", - "set", - "sleep_with_cancel", - "time", - "timeout", - "token", - "yyds_get_message_detail", - "yyds_get_messages" - ] - }, - { - "kind": "FunctionDef", - "name": "generate_username", - "start": 1478, - "end": 1480, - "free_names": [ - "length", - "range", - "secrets", - "string" - ] - }, - { - "kind": "FunctionDef", - "name": "pick_domain", - "start": 1483, - "end": 1494, - "free_names": [ - "Exception", - "api_key", - "get_domains" - ] - }, - { - "kind": "FunctionDef", - "name": "get_email_provider", - "start": 1497, - "end": 1498, - "free_names": [ - "config" - ] - }, - { - "kind": "FunctionDef", - "name": "get_email_and_token", - "start": 1501, - "end": 1544, - "free_names": [ - "Exception", - "api_key", - "cloudflare_create_account", - "cloudflare_create_temp_address", - "cloudflare_get_domains", - "cloudflare_get_token", - "cloudmail_get_email_and_token", - "create_account", - "generate_username", - "get_cloudflare_api_base", - "get_cloudflare_api_key", - "get_duckmail_api_key", - "get_email_provider", - "get_token", - "get_yyds_jwt", - "pick_domain", - "primary_exc", - "secrets", - "yyds_get_email_and_token" - ] - }, - { - "kind": "FunctionDef", - "name": "get_oai_code", - "start": 1547, - "end": 1594, - "free_names": [ - "cancel_callback", - "cloudflare_get_oai_code", - "cloudmail_get_oai_code", - "dev_token", - "duckmail_get_oai_code", - "email", - "get_email_provider", - "get_yyds_jwt", - "log_callback", - "poll_interval", - "resend_callback", - "timeout", - "yyds_get_oai_code" - ] - }, - { - "kind": "FunctionDef", - "name": "extract_verification_code", - "start": 1597, - "end": 1614, - "free_names": [ - "re", - "subject", - "text" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudmail_get_oai_code", - "start": 1617, - "end": 1689, - "free_names": [ - "Exception", - "cancel_callback", - "cloudmail_get_messages", - "dev_token", - "email", - "exc", - "extract_verification_code", - "int", - "isinstance", - "len", - "list", - "log_callback", - "poll_interval", - "raise_if_cancelled", - "re", - "resend_callback", - "sleep_with_cancel", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "duckmail_get_oai_code", - "start": 1692, - "end": 1742, - "free_names": [ - "Exception", - "cancel_callback", - "dev_token", - "email", - "exc", - "extract_verification_code", - "get_message_detail", - "get_messages", - "log_callback", - "poll_interval", - "raise_if_cancelled", - "re", - "set", - "sleep_with_cancel", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "cloudflare_get_oai_code", - "start": 1745, - "end": 1841, - "free_names": [ - "Exception", - "cancel_callback", - "cloudflare_get_message_detail", - "cloudflare_get_messages", - "dev_token", - "email", - "exc", - "extract_verification_code", - "get_cloudflare_api_base", - "int", - "isinstance", - "len", - "log_callback", - "poll_interval", - "raise_if_cancelled", - "re", - "resend_callback", - "sleep_with_cancel", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "generate_random_birthdate", - "start": 1844, - "end": 1852, - "free_names": [ - "dt", - "random" - ] - }, - { - "kind": "FunctionDef", - "name": "response_preview", - "start": 1855, - "end": 1861, - "free_names": [ - "Exception", - "limit", - "re", - "res", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "is_cloudflare_block_response", - "start": 1864, - "end": 1881, - "free_names": [ - "Exception", - "dict", - "res", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "set_birth_date", - "start": 1884, - "end": 1910, - "free_names": [ - "Exception", - "e", - "generate_random_birthdate", - "is_cloudflare_block_response", - "log_callback", - "response_preview", - "session" - ] - }, - { - "kind": "FunctionDef", - "name": "set_tos_accepted", - "start": 1913, - "end": 1940, - "free_names": [ - "Exception", - "e", - "is_cloudflare_block_response", - "len", - "log_callback", - "response_preview", - "session", - "struct" - ] - }, - { - "kind": "FunctionDef", - "name": "encode_grpc_nsfw_settings", - "start": 1943, - "end": 1950, - "free_names": [ - "bytes", - "len", - "struct" - ] - }, - { - "kind": "FunctionDef", - "name": "update_nsfw_settings", - "start": 1953, - "end": 1980, - "free_names": [ - "Exception", - "e", - "encode_grpc_nsfw_settings", - "is_cloudflare_block_response", - "log_callback", - "response_preview", - "session" - ] - }, - { - "kind": "FunctionDef", - "name": "enable_nsfw_for_token", - "start": 1983, - "end": 2008, - "free_names": [ - "Exception", - "cf_clearance", - "e", - "get_proxies", - "get_user_agent", - "log_callback", - "requests", - "set_birth_date", - "set_tos_accepted", - "str", - "token", - "update_nsfw_settings" - ] - }, - { - "kind": "FunctionDef", - "name": "setup_light_theme", - "start": 2019, - "end": 2048, - "free_names": [ - "Exception", - "UI_ACTIVE_BG", - "UI_BG", - "UI_BUTTON_BG", - "UI_ENTRY_BG", - "UI_FG", - "root", - "set", - "ttk" - ] - }, - { - "kind": "FunctionDef", - "name": "tk_label", - "start": 2051, - "end": 2052, - "free_names": [ - "UI_BG", - "UI_FG", - "kwargs", - "parent", - "text", - "tk" - ] - }, - { - "kind": "FunctionDef", - "name": "tk_entry", - "start": 2055, - "end": 2069, - "free_names": [ - "UI_ENTRY_BG", - "UI_FG", - "UI_MUTED_FG", - "kwargs", - "parent", - "textvariable", - "tk", - "width" - ] - }, - { - "kind": "FunctionDef", - "name": "tk_button", - "start": 2072, - "end": 2087, - "free_names": [ - "UI_ACTIVE_BG", - "UI_BUTTON_BG", - "UI_FG", - "command", - "kwargs", - "parent", - "state", - "text", - "tk" - ] - }, - { - "kind": "FunctionDef", - "name": "tk_checkbutton", - "start": 2090, - "end": 2101, - "free_names": [ - "UI_BG", - "UI_FG", - "kwargs", - "parent", - "text", - "tk", - "variable" - ] - }, - { - "kind": "FunctionDef", - "name": "tk_option_menu", - "start": 2104, - "end": 2117, - "free_names": [ - "UI_ACTIVE_BG", - "UI_ENTRY_BG", - "UI_FG", - "parent", - "tk", - "values", - "variable", - "width" - ] - }, - { - "kind": "FunctionDef", - "name": "start_browser", - "start": 2120, - "end": 2161, - "free_names": [ - "Chromium", - "Exception", - "bool", - "create_browser_options", - "exc", - "get_configured_proxy", - "getattr", - "log_callback", - "min", - "prepare_browser_proxy", - "range", - "time", - "use_proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "stop_browser", - "start": 2164, - "end": 2174, - "free_names": [ - "Exception", - "stop_browser_proxy_bridge" - ] - }, - { - "kind": "FunctionDef", - "name": "restart_browser", - "start": 2177, - "end": 2179, - "free_names": [ - "log_callback", - "start_browser", - "stop_browser", - "use_proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "cleanup_runtime_memory", - "start": 2182, - "end": 2194, - "free_names": [ - "Exception", - "exc", - "gc", - "log_callback", - "reason", - "shutdown_mint_browsers", - "stop_browser" - ] - }, - { - "kind": "FunctionDef", - "name": "refresh_active_page", - "start": 2197, - "end": 2209, - "free_names": [ - "Exception", - "browser", - "restart_browser" - ] - }, - { - "kind": "FunctionDef", - "name": "click_email_signup_button", - "start": 2212, - "end": 2277, - "free_names": [ - "Exception", - "cancel_callback", - "isinstance", - "log_callback", - "page", - "raise_if_cancelled", - "sleep_with_cancel", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "open_signup_page", - "start": 2280, - "end": 2321, - "free_names": [ - "Exception", - "SIGNUP_URL", - "browser", - "browser_started_with_proxy", - "cancel_callback", - "click_email_signup_button", - "e", - "get_configured_proxy", - "log_callback", - "page_has_proxy_error", - "raise_if_cancelled", - "restart_browser", - "sleep_with_cancel", - "start_browser" - ] - }, - { - "kind": "FunctionDef", - "name": "has_profile_form", - "start": 2324, - "end": 2338, - "free_names": [ - "Exception", - "bool", - "page", - "refresh_active_page" - ] - }, - { - "kind": "FunctionDef", - "name": "fill_email_and_submit", - "start": 2341, - "end": 2598, - "free_names": [ - "Exception", - "cancel_callback", - "dict", - "get_email_and_token", - "isinstance", - "log_callback", - "page", - "raise_if_cancelled", - "sleep_with_cancel", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "fill_code_and_submit", - "start": 2601, - "end": 2740, - "free_names": [ - "Exception", - "cancel_callback", - "dev_token", - "email", - "get_oai_code", - "log_callback", - "page", - "raise_if_cancelled", - "sleep_with_cancel", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "getTurnstileToken", - "start": 2743, - "end": 2820, - "free_names": [ - "Exception", - "cancel_callback", - "len", - "log_callback", - "page", - "raise_if_cancelled", - "range", - "sleep_with_cancel", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "build_profile", - "start": 2823, - "end": 2845, - "free_names": [ - "random", - "secrets" - ] - }, - { - "kind": "FunctionDef", - "name": "fill_profile_and_submit", - "start": 2848, - "end": 3080, - "free_names": [ - "Exception", - "build_profile", - "cancel_callback", - "cf_exc", - "getTurnstileToken", - "isinstance", - "log_callback", - "page", - "raise_if_cancelled", - "random", - "sleep_with_cancel", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "wait_for_sso_cookie", - "start": 3083, - "end": 3232, - "free_names": [ - "AccountRetryNeeded", - "Exception", - "PageDisconnectedError", - "cancel_callback", - "cf_exc", - "dict", - "exc", - "getTurnstileToken", - "getattr", - "isinstance", - "log_callback", - "page", - "raise_if_cancelled", - "refresh_active_page", - "set", - "sleep_with_cancel", - "sorted", - "str", - "time", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "maybe_export_cpa_xai_after_success", - "start": 3236, - "end": 3269, - "free_names": [ - "Exception", - "bool", - "cancel_callback", - "config", - "email", - "exc", - "export_cpa_xai_for_account", - "log_callback", - "page", - "password", - "sso", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_save_mail_credential", - "start": 3273, - "end": 3279, - "free_names": [ - "Exception", - "__file__", - "credential", - "email", - "exc", - "log_callback", - "log_exception", - "os", - "save_mail_credential" - ] - }, - { - "kind": "FunctionDef", - "name": "_append_account_line", - "start": 3282, - "end": 3284, - "free_names": [ - "append_account_line", - "email", - "password", - "path", - "sso" - ] - }, - { - "kind": "FunctionDef", - "name": "_queue_unsaved_account", - "start": 3287, - "end": 3293, - "free_names": [ - "Exception", - "error", - "exc", - "log_callback", - "log_exception", - "path", - "payload", - "queue_unsaved_account" - ] - }, - { - "kind": "FunctionDef", - "name": "retry_pending_file", - "start": 3296, - "end": 3298, - "free_names": [ - "_retry_pending_file", - "log_callback", - "output_path", - "pending_path" - ] - }, - { - "kind": "FunctionDef", - "name": "run_registration_common", - "start": 3301, - "end": 3336, - "free_names": [ - "AccountRetryNeeded", - "MEMORY_CLEANUP_INTERVAL", - "RegistrationCallbacks", - "RegistrationCancelled", - "RegistrationOperations", - "_append_account_line", - "_queue_unsaved_account", - "_save_mail_credential", - "accounts_output_file", - "add_token_to_grok2api_pools", - "bool", - "browser", - "cancel_callback", - "cleanup_runtime_memory", - "config", - "count", - "email", - "enable_nsfw_for_token", - "error", - "fill_code_and_submit", - "fill_email_and_submit", - "fill_profile_and_submit", - "log_callback", - "maybe_export_cpa_xai_after_success", - "observer", - "open_signup_page", - "password", - "payload", - "reason", - "restart_browser", - "run_batch", - "seconds", - "sleep_with_cancel", - "sso", - "start_browser", - "token", - "wait_for_sso_cookie" - ] - }, - { - "kind": "ClassDef", - "name": "GrokRegisterGUI", - "start": 3339, - "end": 3710, - "free_names": [ - "ConfigError", - "Exception", - "UI_BG", - "UI_BUTTON_BG", - "UI_ENTRY_BG", - "UI_FG", - "UI_MUTED_FG", - "UI_PANEL_BG", - "ValueError", - "__file__", - "account", - "bool", - "column", - "columnspan", - "config", - "datetime", - "exc", - "int", - "len", - "load_config", - "log_exception", - "message", - "messagebox", - "os", - "output", - "print", - "queue", - "root", - "row", - "run_registration_common", - "save_config", - "scrolledtext", - "self", - "sticky", - "str", - "sys", - "text", - "threading", - "tk", - "tk_button", - "tk_checkbutton", - "tk_entry", - "tk_label", - "tk_option_menu", - "validate_run_requirements", - "widget" - ] - }, - { - "kind": "ClassDef", - "name": "CliStopController", - "start": 3715, - "end": 3723, - "free_names": [ - "self" - ] - }, - { - "kind": "FunctionDef", - "name": "cli_log", - "start": 3726, - "end": 3728, - "free_names": [ - "datetime", - "message", - "print" - ] - }, - { - "kind": "FunctionDef", - "name": "run_registration_cli", - "start": 3731, - "end": 3764, - "free_names": [ - "CliStopController", - "Exception", - "KeyboardInterrupt", - "__file__", - "cli_log", - "count", - "datetime", - "exc", - "log_exception", - "os", - "run_registration_common" - ] - }, - { - "kind": "FunctionDef", - "name": "main_cli", - "start": 3767, - "end": 3792, - "free_names": [ - "ConfigError", - "KeyboardInterrupt", - "cli_log", - "config", - "exc", - "input", - "int", - "load_config", - "run_registration_cli", - "validate_run_requirements" - ] - }, - { - "kind": "FunctionDef", - "name": "main", - "start": 3795, - "end": 3831, - "free_names": [ - "ConfigError", - "Exception", - "GrokRegisterGUI", - "TK_AVAILABLE", - "TK_IMPORT_ERROR", - "cli_log", - "exc", - "len", - "log_exception", - "main_cli", - "messagebox", - "print", - "retry_pending_file", - "setup_light_theme", - "str", - "sys", - "tk" - ] - } - ] - }, - "registration_flow.py": { - "lines": 319, - "definitions": [ - { - "kind": "ClassDef", - "name": "RegistrationCallbacks", - "start": 7, - "end": 9, - "free_names": [ - "Callable", - "bool", - "dataclass", - "str" - ] - }, - { - "kind": "ClassDef", - "name": "RegistrationOperations", - "start": 13, - "end": 31, - "free_names": [ - "Any", - "Callable", - "Dict", - "Tuple", - "bool", - "dataclass", - "float", - "str", - "type" - ] - }, - { - "kind": "ClassDef", - "name": "RegistrationResult", - "start": 35, - "end": 42, - "free_names": [ - "Any", - "Dict", - "bool", - "dataclass", - "dict", - "field", - "str" - ] - }, - { - "kind": "ClassDef", - "name": "OutputResult", - "start": 46, - "end": 52, - "free_names": [ - "Any", - "Dict", - "bool", - "dataclass", - "dict", - "field", - "str" - ] - }, - { - "kind": "ClassDef", - "name": "RegistrationSettings", - "start": 56, - "end": 61, - "free_names": [ - "bool", - "dataclass", - "int" - ] - }, - { - "kind": "ClassDef", - "name": "BatchResult", - "start": 65, - "end": 72, - "free_names": [ - "bool", - "dataclass", - "field", - "int", - "list" - ] - }, - { - "kind": "FunctionDef", - "name": "register_one_account", - "start": 75, - "end": 125, - "free_names": [ - "Exception", - "RegistrationResult", - "RuntimeError", - "callbacks", - "enable_nsfw", - "exc", - "max_mail_retry", - "ops", - "range", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "persist_account_result", - "start": 128, - "end": 190, - "free_names": [ - "Exception", - "OutputResult", - "TypeError", - "bool", - "callbacks", - "dict", - "exc", - "isinstance", - "ops", - "pending_exc", - "result", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_notify_observer", - "start": 193, - "end": 197, - "free_names": [ - "Exception", - "account", - "callbacks", - "exc", - "observer", - "output", - "result" - ] - }, - { - "kind": "FunctionDef", - "name": "_run_cleanup_safely", - "start": 200, - "end": 206, - "free_names": [ - "Exception", - "callbacks", - "exc", - "ops", - "reason" - ] - }, - { - "kind": "FunctionDef", - "name": "_prepare_next_account", - "start": 209, - "end": 225, - "free_names": [ - "callbacks", - "ops", - "result", - "settings" - ] - }, - { - "kind": "FunctionDef", - "name": "run_batch", - "start": 228, - "end": 318, - "free_names": [ - "BatchResult", - "Exception", - "RegistrationSettings", - "_notify_observer", - "_prepare_next_account", - "_run_cleanup_safely", - "any", - "bool", - "callbacks", - "cleanup_interval", - "count", - "dict", - "enable_nsfw", - "exc", - "int", - "isinstance", - "max_mail_retry", - "max_slot_retry", - "observer", - "ops", - "persist_account_result", - "register_one_account" - ] - } - ] - }, - "cpa_xai/browser_confirm.py": { - "lines": 972, - "definitions": [ - { - "kind": "FunctionDef", - "name": "_noop_log", - "start": 23, - "end": 24, - "free_names": [ - "str" - ] - }, - { - "kind": "ClassDef", - "name": "BrowserConfirmError", - "start": 27, - "end": 28, - "free_names": [ - "RuntimeError" - ] - }, - { - "kind": "FunctionDef", - "name": "_sleep", - "start": 31, - "end": 32, - "free_names": [ - "float", - "sec", - "time" - ] - }, - { - "kind": "FunctionDef", - "name": "_project_root", - "start": 35, - "end": 36, - "free_names": [ - "Path", - "__file__" - ] - }, - { - "kind": "FunctionDef", - "name": "_debug_shot_dir", - "start": 39, - "end": 42, - "free_names": [ - "Path", - "_project_root" - ] - }, - { - "kind": "FunctionDef", - "name": "_safe_tag", - "start": 45, - "end": 53, - "free_names": [ - "str", - "value" - ] - }, - { - "kind": "FunctionDef", - "name": "_save_debug_shot", - "start": 56, - "end": 98, - "free_names": [ - "Any", - "Exception", - "LogFn", - "Optional", - "TypeError", - "_debug_shot_dir", - "_noop_log", - "_norm", - "_page_url", - "_safe_tag", - "_visible_text", - "email", - "exc", - "hasattr", - "log", - "page", - "str", - "tag", - "time" - ] - }, - { - "kind": "FunctionDef", - "name": "_is_turnstile_challenge", - "start": 101, - "end": 115, - "free_names": [ - "any", - "bool", - "str", - "text" - ] - }, - { - "kind": "FunctionDef", - "name": "create_standalone_page", - "start": 118, - "end": 219, - "free_names": [ - "BrowserConfirmError", - "Chromium", - "ChromiumOptions", - "Exception", - "ImportError", - "LogFn", - "Optional", - "Path", - "__file__", - "_noop_log", - "_register_mint_browser", - "bool", - "close_standalone", - "create_browser_options", - "exc", - "headless", - "log", - "os", - "prepare_chromium_proxy", - "proxy", - "proxy_log_label", - "resolve_proxy", - "setattr", - "str", - "sys" - ] - }, - { - "kind": "FunctionDef", - "name": "close_standalone", - "start": 222, - "end": 235, - "free_names": [ - "Any", - "Exception", - "_unregister_mint_browser", - "browser", - "getattr" - ] - }, - { - "kind": "FunctionDef", - "name": "_register_mint_browser", - "start": 243, - "end": 247, - "free_names": [ - "Any", - "_mint_registry", - "_mint_registry_lock", - "browser" - ] - }, - { - "kind": "FunctionDef", - "name": "_unregister_mint_browser", - "start": 250, - "end": 254, - "free_names": [ - "Any", - "_mint_registry", - "_mint_registry_lock", - "browser" - ] - }, - { - "kind": "FunctionDef", - "name": "_mint_tls_get", - "start": 257, - "end": 262, - "free_names": [ - "_mint_tls", - "getattr" - ] - }, - { - "kind": "FunctionDef", - "name": "clear_page_session", - "start": 265, - "end": 300, - "free_names": [ - "Any", - "Exception", - "LogFn", - "Optional", - "_noop_log", - "browser", - "exc", - "isinstance", - "list", - "log", - "page" - ] - }, - { - "kind": "FunctionDef", - "name": "normalize_cookies", - "start": 303, - "end": 351, - "free_names": [ - "Any", - "dict", - "isinstance", - "list", - "str", - "tuple" - ] - }, - { - "kind": "FunctionDef", - "name": "inject_cookies", - "start": 354, - "end": 413, - "free_names": [ - "Any", - "Exception", - "LogFn", - "Optional", - "_noop_log", - "_sleep", - "bool", - "cookies", - "exc", - "getattr", - "int", - "len", - "log", - "normalize_cookies", - "page", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "acquire_mint_browser", - "start": 416, - "end": 444, - "free_names": [ - "Exception", - "LogFn", - "Optional", - "_mint_tls_get", - "_noop_log", - "bool", - "clear_page_session", - "close_standalone", - "create_standalone_page", - "headless", - "int", - "log", - "max", - "proxy", - "recycle_every", - "reuse", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "release_mint_browser", - "start": 447, - "end": 454, - "free_names": [ - "LogFn", - "Optional", - "_mint_tls_get", - "_noop_log", - "bool", - "int", - "log", - "owned", - "success" - ] - }, - { - "kind": "FunctionDef", - "name": "shutdown_mint_browsers", - "start": 457, - "end": 466, - "free_names": [ - "Exception", - "_mint_registry", - "_mint_registry_lock", - "_mint_tls_get", - "close_standalone", - "list" - ] - }, - { - "kind": "FunctionDef", - "name": "_page_url", - "start": 469, - "end": 473, - "free_names": [ - "Any", - "Exception", - "getattr", - "page", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_visible_text", - "start": 476, - "end": 480, - "free_names": [ - "Any", - "Exception", - "page", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_norm", - "start": 483, - "end": 484, - "free_names": [ - "re", - "str", - "text" - ] - }, - { - "kind": "FunctionDef", - "name": "_find_button_exact", - "start": 487, - "end": 495, - "free_names": [ - "Any", - "Exception", - "Optional", - "label", - "page", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_cookie_banner_visible", - "start": 498, - "end": 504, - "free_names": [ - "any", - "bool", - "str", - "text" - ] - }, - { - "kind": "FunctionDef", - "name": "_dismiss_cookie_banner", - "start": 507, - "end": 512, - "free_names": [ - "Any", - "LogFn", - "_click_exact", - "bool", - "log", - "page" - ] - }, - { - "kind": "FunctionDef", - "name": "_click_exact", - "start": 515, - "end": 532, - "free_names": [ - "Any", - "Exception", - "LogFn", - "Optional", - "_find_button_exact", - "bool", - "exc", - "labels", - "log", - "page", - "real", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_wait_turnstile", - "start": 535, - "end": 560, - "free_names": [ - "Any", - "BrowserConfirmError", - "Exception", - "LogFn", - "_is_turnstile_challenge", - "_save_debug_shot", - "_sleep", - "_visible_text", - "bool", - "email", - "float", - "int", - "log", - "page", - "raise_on_timeout", - "str", - "time", - "timeout_sec" - ] - }, - { - "kind": "FunctionDef", - "name": "_fill", - "start": 563, - "end": 599, - "free_names": [ - "Any", - "Exception", - "LogFn", - "bool", - "label", - "log", - "page", - "selector", - "str", - "value" - ] - }, - { - "kind": "FunctionDef", - "name": "_fill_input", - "start": 602, - "end": 603, - "free_names": [ - "Any", - "LogFn", - "_fill", - "bool", - "label", - "log", - "page", - "selector", - "str", - "value" - ] - }, - { - "kind": "FunctionDef", - "name": "_detect_auth_error", - "start": 606, - "end": 630, - "free_names": [ - "Optional", - "str", - "text", - "url" - ] - }, - { - "kind": "FunctionDef", - "name": "approve_device_code", - "start": 633, - "end": 832, - "free_names": [ - "Any", - "BrowserConfirmError", - "Exception", - "LogFn", - "Optional", - "TypeError", - "_click_exact", - "_cookie_banner_visible", - "_detect_auth_error", - "_dismiss_cookie_banner", - "_fill", - "_is_turnstile_challenge", - "_noop_log", - "_norm", - "_page_url", - "_save_debug_shot", - "_sleep", - "_visible_text", - "_wait_turnstile", - "exc", - "float", - "log", - "page", - "range", - "stop_event", - "str", - "threading", - "time", - "timeout_sec", - "verification_uri_complete" - ] - }, - { - "kind": "FunctionDef", - "name": "mint_with_browser", - "start": 835, - "end": 972, - "free_names": [ - "Any", - "BrowserConfirmError", - "Callable", - "Exception", - "LogFn", - "OAuthDeviceError", - "Optional", - "_noop_log", - "_norm", - "_page_url", - "_sleep", - "_visible_text", - "acquire_mint_browser", - "approve_device_code", - "bool", - "browser_timeout_sec", - "cancel", - "close_standalone", - "cookies", - "email", - "exc", - "float", - "force_standalone", - "headless", - "inject_cookies", - "int", - "max", - "min", - "page", - "password", - "poll_device_token", - "poll_log", - "poll_timeout_sec", - "proxy", - "proxy_log_label", - "range", - "recycle_every", - "release_mint_browser", - "request_device_code", - "request_timeout_sec", - "resolve_proxy", - "reuse_browser", - "set_runtime_proxy", - "str", - "threading", - "time" - ] - } - ] - }, - "cpa_export.py": { - "lines": 163, - "definitions": [ - { - "kind": "FunctionDef", - "name": "_ensure_cpa_xai_on_path", - "start": 14, - "end": 24, - "free_names": [ - "Path", - "_ROOT", - "os", - "str", - "sys", - "tools_dir" - ] - }, - { - "kind": "FunctionDef", - "name": "export_cookies_from_page", - "start": 27, - "end": 53, - "free_names": [ - "Exception", - "TypeError", - "dict", - "getattr", - "isinstance", - "list", - "page" - ] - }, - { - "kind": "FunctionDef", - "name": "export_cpa_xai_for_account", - "start": 56, - "end": 163, - "free_names": [ - "Exception", - "Path", - "_DEFAULT_AUTH_DIR", - "_ROOT", - "_ensure_cpa_xai_on_path", - "bool", - "cancel_callback", - "config", - "cookies", - "dict", - "email", - "exc", - "export_cookies_from_page", - "float", - "int", - "isinstance", - "list", - "log_callback", - "message", - "mint_and_export", - "open", - "os", - "page", - "password", - "shutil", - "sso", - "str", - "time" - ] - } - ] - }, - "cf_mail_debug.py": { - "lines": 240, - "definitions": [ - { - "kind": "FunctionDef", - "name": "extract_code", - "start": 14, - "end": 30, - "free_names": [ - "Optional", - "re", - "str", - "subject", - "text" - ] - }, - { - "kind": "FunctionDef", - "name": "json_or_text", - "start": 33, - "end": 38, - "free_names": [ - "Any", - "Dict", - "Exception", - "Optional", - "Tuple", - "requests", - "resp", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "generate_username", - "start": 41, - "end": 44, - "free_names": [ - "int", - "length", - "range", - "secrets", - "str", - "string" - ] - }, - { - "kind": "FunctionDef", - "name": "normalize_path", - "start": 47, - "end": 50, - "free_names": [ - "default_path", - "path", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "build_auth_headers", - "start": 53, - "end": 66, - "free_names": [ - "Dict", - "api_key", - "auth_mode", - "bool", - "content_type", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "create_address", - "start": 69, - "end": 107, - "free_names": [ - "Any", - "Dict", - "RuntimeError", - "Tuple", - "api_base", - "api_key", - "auth_mode", - "build_auth_headers", - "create_path", - "domain", - "generate_username", - "json_or_text", - "name", - "normalize_path", - "requests", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "fetch_box", - "start": 110, - "end": 128, - "free_names": [ - "Any", - "Dict", - "List", - "api_base", - "dict", - "isinstance", - "json_or_text", - "jwt", - "list", - "params", - "path", - "requests", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "probe_all_boxes", - "start": 131, - "end": 144, - "free_names": [ - "Any", - "Dict", - "List", - "Tuple", - "api_base", - "fetch_box", - "jwt", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_detail", - "start": 147, - "end": 161, - "free_names": [ - "Any", - "Dict", - "Exception", - "api_base", - "dict", - "isinstance", - "json_or_text", - "jwt", - "mail_id", - "requests", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "flatten_mail_text", - "start": 164, - "end": 179, - "free_names": [ - "Any", - "Dict", - "List", - "Tuple", - "detail", - "isinstance", - "item", - "list", - "re", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "main", - "start": 182, - "end": 236, - "free_names": [ - "argparse", - "create_address", - "extract_code", - "flatten_mail_text", - "get_detail", - "int", - "len", - "max", - "print", - "probe_all_boxes", - "set", - "time" - ] - } - ] - }, - "cpa_xai/oauth_device.py": { - "lines": 328, - "definitions": [ - { - "kind": "ClassDef", - "name": "OAuthDeviceError", - "start": 18, - "end": 19, - "free_names": [ - "RuntimeError" - ] - }, - { - "kind": "ClassDef", - "name": "DeviceCodeSession", - "start": 22, - "end": 41, - "free_names": [ - "device_code", - "expires_in", - "int", - "interval", - "object", - "raw", - "self", - "token_endpoint", - "user_code", - "verification_uri", - "verification_uri_complete" - ] - }, - { - "kind": "ClassDef", - "name": "TokenResult", - "start": 44, - "end": 51, - "free_names": [ - "access_token", - "expires_in", - "id_token", - "int", - "object", - "raw", - "refresh_token", - "self", - "token_type" - ] - }, - { - "kind": "FunctionDef", - "name": "_build_opener", - "start": 54, - "end": 59, - "free_names": [ - "proxy", - "resolve_proxy", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "_validate_endpoint", - "start": 62, - "end": 72, - "free_names": [ - "OAuthDeviceError", - "field_name", - "raw_url", - "str", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "discover", - "start": 75, - "end": 111, - "free_names": [ - "DISCOVERY_URL", - "Exception", - "OAuthDeviceError", - "_build_opener", - "_check_cancel", - "_is_transient_net_error", - "_sleep_with_cancel", - "_validate_endpoint", - "cancel", - "exc", - "float", - "getattr", - "int", - "json", - "max", - "proxy", - "range", - "retries", - "timeout", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "_check_cancel", - "start": 114, - "end": 116, - "free_names": [ - "OAuthDeviceError", - "cancel" - ] - }, - { - "kind": "FunctionDef", - "name": "_sleep_with_cancel", - "start": 119, - "end": 124, - "free_names": [ - "_check_cancel", - "cancel", - "float", - "max", - "min", - "seconds", - "time" - ] - }, - { - "kind": "FunctionDef", - "name": "_is_transient_net_error", - "start": 127, - "end": 167, - "free_names": [ - "BaseException", - "BrokenPipeError", - "ConnectionAbortedError", - "ConnectionRefusedError", - "ConnectionResetError", - "Exception", - "OSError", - "TimeoutError", - "_is_transient_net_error", - "_ssl", - "any", - "exc", - "getattr", - "isinstance", - "str", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "_post_form", - "start": 170, - "end": 206, - "free_names": [ - "Exception", - "OAuthDeviceError", - "_build_opener", - "_check_cancel", - "_is_transient_net_error", - "_sleep_with_cancel", - "cancel", - "exc", - "float", - "form", - "getattr", - "int", - "json", - "max", - "proxy", - "range", - "retries", - "retry_sleep", - "timeout", - "url", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "request_device_code", - "start": 209, - "end": 243, - "free_names": [ - "CLIENT_ID", - "DeviceCodeSession", - "OAuthDeviceError", - "SCOPE", - "_check_cancel", - "_post_form", - "cancel", - "client_id", - "dict", - "discover", - "int", - "isinstance", - "max", - "proxy", - "retries", - "scope", - "str", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "poll_device_token", - "start": 246, - "end": 328, - "free_names": [ - "CLIENT_ID", - "Exception", - "OAuthDeviceError", - "TokenResult", - "_is_transient_net_error", - "_post_form", - "_sleep_with_cancel", - "cancel", - "client_id", - "device_code", - "dict", - "exc", - "expires_in", - "float", - "int", - "interval", - "isinstance", - "log", - "max", - "min", - "proxy", - "str", - "time", - "timeout", - "token_endpoint" - ] - } - ] - }, - "cpa_xai/proxyutil.py": { - "lines": 239, - "definitions": [ - { - "kind": "FunctionDef", - "name": "set_runtime_proxy", - "start": 23, - "end": 25, - "free_names": [ - "_tls", - "proxy", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "get_runtime_proxy", - "start": 28, - "end": 29, - "free_names": [ - "_tls", - "getattr" - ] - }, - { - "kind": "FunctionDef", - "name": "resolve_proxy", - "start": 32, - "end": 43, - "free_names": [ - "explicit", - "get_runtime_proxy", - "os", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "_parse_proxy", - "start": 46, - "end": 55, - "free_names": [ - "Exception", - "proxy", - "str", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "_safe_port", - "start": 58, - "end": 62, - "free_names": [ - "Exception", - "parsed" - ] - }, - { - "kind": "FunctionDef", - "name": "_has_proxy_auth", - "start": 65, - "end": 67, - "free_names": [ - "_parse_proxy", - "bool", - "proxy" - ] - }, - { - "kind": "FunctionDef", - "name": "_recv_until_headers", - "start": 70, - "end": 78, - "free_names": [ - "len", - "limit", - "sock", - "timeout" - ] - }, - { - "kind": "FunctionDef", - "name": "_relay", - "start": 81, - "end": 94, - "free_names": [ - "left", - "right", - "select", - "timeout" - ] - }, - { - "kind": "ClassDef", - "name": "_BridgeServer", - "start": 97, - "end": 99, - "free_names": [ - "socketserver" - ] - }, - { - "kind": "ClassDef", - "name": "_BridgeHandler", - "start": 102, - "end": 136, - "free_names": [ - "Exception", - "_recv_until_headers", - "_relay", - "self", - "socketserver" - ] - }, - { - "kind": "ClassDef", - "name": "LocalAuthProxyBridge", - "start": 139, - "end": 195, - "free_names": [ - "Exception", - "ValueError", - "_BridgeHandler", - "_BridgeServer", - "_parse_proxy", - "_safe_port", - "base64", - "data", - "object", - "proxy_url", - "self", - "socket", - "ssl", - "threading", - "urllib" - ] - }, - { - "kind": "FunctionDef", - "name": "proxy_for_chromium", - "start": 198, - "end": 212, - "free_names": [ - "ValueError", - "_has_proxy_auth", - "_parse_proxy", - "_safe_port", - "proxy", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "prepare_chromium_proxy", - "start": 215, - "end": 225, - "free_names": [ - "LocalAuthProxyBridge", - "_has_proxy_auth", - "log", - "proxy", - "proxy_for_chromium", - "str" - ] - }, - { - "kind": "FunctionDef", - "name": "proxy_log_label", - "start": 228, - "end": 239, - "free_names": [ - "_parse_proxy", - "_safe_port", - "proxy", - "str" - ] - } - ] - } -} diff --git a/registration_browser.py b/registration_browser.py new file mode 100644 index 0000000..a8e8342 --- /dev/null +++ b/registration_browser.py @@ -0,0 +1,1300 @@ +"""Registration browser lifecycle and page automation.""" +import gc +import random +import re +import secrets +import struct +import time + +from DrissionPage import Chromium +from DrissionPage.errors import PageDisconnectedError +from curl_cffi import requests + +browser = None +page = None +browser_proxy_bridge = None +browser_started_with_proxy = False +cf_clearance = "" +SIGNUP_URL = "https://accounts.x.ai/sign-up" +_OWN_NAMES = {'is_cloudflare_block_response', 'response_preview', 'start_browser', 'enable_nsfw_for_token', 'stop_browser_proxy_bridge', 'set_tos_accepted', 'fill_email_and_submit', 'getTurnstileToken', 'set_birth_date', 'generate_random_birthdate', 'fill_profile_and_submit', 'click_email_signup_button', 'wait_for_sso_cookie', 'fill_code_and_submit', 'build_profile', 'cleanup_runtime_memory', 'open_signup_page', 'stop_browser', 'encode_grpc_nsfw_settings', 'restart_browser', 'has_profile_form', 'update_nsfw_settings', 'refresh_active_page'} + + +def bind_runtime(namespace): + for name, value in namespace.items(): + if name.startswith("__") or name in _OWN_NAMES or name in { + "browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance", + }: + continue + globals()[name] = value + + +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)}" + +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 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)}" + ) + + diff --git a/tests/test_browser_session.py b/tests/test_browser_session.py new file mode 100644 index 0000000..035045b --- /dev/null +++ b/tests/test_browser_session.py @@ -0,0 +1,36 @@ +import unittest +from cpa_xai import browser_session + + +class Bridge: + def __init__(self): + self.stops = 0 + def stop(self): + self.stops += 1 + + +class Browser: + def __init__(self, bridge=None): + self._cpa_proxy_bridge = bridge + self.quits = 0 + def quit(self): + self.quits += 1 + + +class BrowserSessionTests(unittest.TestCase): + def test_close_standalone_closes_browser_and_bridge(self): + bridge = Bridge() + browser = Browser(bridge) + browser_session._register_mint_browser(browser) + browser_session.close_standalone(browser) + self.assertEqual(browser.quits, 1) + self.assertEqual(bridge.stops, 1) + + def test_normalize_cookies_rejects_invalid_items(self): + value = browser_session.normalize_cookies([None, {"name": "a", "value": "b"}, "bad"]) + self.assertEqual(len(value), 1) + self.assertEqual(value[0]["name"], "a") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpa_core.py b/tests/test_cpa_core.py new file mode 100644 index 0000000..d168d65 --- /dev/null +++ b/tests/test_cpa_core.py @@ -0,0 +1,32 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from cpa_xai.mint import mint_and_export +from cpa_xai.schema import build_cpa_xai_auth, jwt_payload +from cpa_xai.writer import write_cpa_xai_auth + + +class CpaCoreTests(unittest.TestCase): + def test_schema_rejects_missing_tokens(self): + with self.assertRaises(ValueError): + build_cpa_xai_auth("a@example.com", "", "refresh") + with self.assertRaises(ValueError): + jwt_payload("not-a-jwt") + + def test_writer_failure_does_not_leave_temp_file(self): + with tempfile.TemporaryDirectory() as directory: + with patch("cpa_xai.writer.os.replace", side_effect=OSError("disk")): + with self.assertRaises(OSError): + write_cpa_xai_auth(directory, {"email": "a@example.com"}, "a.json") + self.assertEqual([p.name for p in Path(directory).iterdir()], []) + + def test_mint_rejects_missing_identity_without_browser(self): + result = mint_and_export("", "", tempfile.gettempdir()) + self.assertFalse(result["ok"]) + self.assertIn("missing", result["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_module_compatibility.py b/tests/test_module_compatibility.py new file mode 100644 index 0000000..b08b1a7 --- /dev/null +++ b/tests/test_module_compatibility.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import patch + +import app_config +import grok_register_ttk as app +import mail_service +import registration_browser +from cpa_xai import browser_confirm +from cpa_xai import browser_session + + +class ModuleCompatibilityTests(unittest.TestCase): + def test_config_object_is_shared(self): + self.assertIs(app.config, app_config.config) + + def test_original_public_functions_remain_available(self): + names = ['_normalize_sso_token', '_pick_list_payload', 'add_token_to_grok2api_local_pool', 'add_token_to_grok2api_pools', 'add_token_to_grok2api_remote_pool', 'build_profile', 'cleanup_runtime_memory', 'click_email_signup_button', 'cloudflare_apply_auth_params', 'cloudflare_build_headers', 'cloudflare_create_account', 'cloudflare_create_temp_address', 'cloudflare_get_domains', 'cloudflare_get_message_detail', 'cloudflare_get_messages', 'cloudflare_get_oai_code', 'cloudflare_get_token', 'cloudflare_is_admin_create_path', 'cloudflare_next_default_domain', 'cloudmail_get_email_and_token', 'cloudmail_get_messages', 'cloudmail_get_oai_code', 'cloudmail_next_domain', 'create_account', 'create_browser_options', 'duckmail_get_oai_code', 'enable_nsfw_for_token', 'encode_grpc_nsfw_settings', 'extract_verification_code', 'fill_code_and_submit', 'fill_email_and_submit', 'fill_profile_and_submit', 'generate_random_birthdate', 'generate_username', 'getTurnstileToken', 'get_cloudflare_api_base', 'get_cloudflare_api_key', 'get_cloudflare_auth_mode', 'get_cloudflare_path', 'get_cloudmail_api_base', 'get_cloudmail_path', 'get_cloudmail_public_token', 'get_domains', 'get_duckmail_api_key', 'get_email_and_token', 'get_email_provider', 'get_grok2api_remote_api_bases', 'get_message_detail', 'get_messages', 'get_oai_code', 'get_token', 'get_user_agent', 'get_yyds_api_key', 'get_yyds_jwt', 'has_profile_form', 'http_get', 'http_post', 'is_cloudflare_block_response', 'open_signup_page', 'pick_domain', 'refresh_active_page', 'resolve_grok2api_local_token_file', 'response_preview', 'restart_browser', 'set_birth_date', 'set_tos_accepted', 'start_browser', 'stop_browser', 'stop_browser_proxy_bridge', 'update_nsfw_settings', 'wait_for_sso_cookie', 'yyds_create_account', 'yyds_generate_username', 'yyds_get_domains', 'yyds_get_email_and_token', 'yyds_get_message_detail', 'yyds_get_messages', 'yyds_get_oai_code', 'yyds_get_token', 'yyds_pick_domain'] + for name in names: + self.assertTrue(callable(getattr(app, name)), name) + + def test_mail_wrapper_delegates(self): + with patch.object(mail_service, "get_email_provider", return_value="duckmail") as mocked: + self.assertEqual(app.get_email_provider(), "duckmail") + mocked.assert_called_once_with() + + def test_browser_confirm_reexports_session_api(self): + self.assertIs(browser_confirm.close_standalone, browser_session.close_standalone) + self.assertIs(browser_confirm.normalize_cookies, browser_session.normalize_cookies) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_device.py b/tests/test_oauth_device.py new file mode 100644 index 0000000..59a02eb --- /dev/null +++ b/tests/test_oauth_device.py @@ -0,0 +1,69 @@ +import json +import unittest +from unittest.mock import patch + +from cpa_xai import oauth_device as oauth + + +class Response: + def __init__(self, body, status=200): + self.body = body.encode("utf-8") + self.status = status + def __enter__(self): + return self + def __exit__(self, *args): + return False + def read(self): + return self.body + + +class Opener: + def __init__(self, actions): + self.actions = list(actions) + self.calls = 0 + def open(self, request, timeout=None): + self.calls += 1 + action = self.actions.pop(0) + if isinstance(action, BaseException): + raise action + return action + + +class OAuthDeviceTests(unittest.TestCase): + def test_discovery_success(self): + payload = {"device_authorization_endpoint": "https://auth.x.ai/device", "token_endpoint": "https://auth.x.ai/token"} + opener = Opener([Response(json.dumps(payload))]) + with patch.object(oauth, "_build_opener", return_value=opener): + self.assertEqual(oauth.discover(retries=0)["token_endpoint"], payload["token_endpoint"]) + + def test_discovery_cancelled_before_request(self): + with self.assertRaisesRegex(oauth.OAuthDeviceError, "cancelled"): + oauth.discover(cancel=lambda: True) + + def test_discovery_retries_transient_error(self): + payload = {"device_authorization_endpoint": "https://auth.x.ai/device", "token_endpoint": "https://auth.x.ai/token"} + opener = Opener([TimeoutError("slow"), Response(json.dumps(payload))]) + with patch.object(oauth, "_build_opener", return_value=opener), patch.object(oauth, "_sleep_with_cancel"): + oauth.discover(retries=1) + self.assertEqual(opener.calls, 2) + + def test_post_form_returns_non_json_body(self): + opener = Opener([Response("not-json", status=502)]) + with patch.object(oauth, "_build_opener", return_value=opener): + status, payload = oauth._post_form("https://auth.x.ai/token", {}, retries=0) + self.assertEqual((status, payload), (502, "not-json")) + + def test_slow_down_increases_wait(self): + responses = [ + (400, {"error": "slow_down"}), + (200, {"access_token": "a", "refresh_token": "r"}), + ] + waits = [] + with patch.object(oauth, "_post_form", side_effect=responses), patch.object(oauth, "_sleep_with_cancel", side_effect=lambda seconds, cancel=None: waits.append(seconds)): + result = oauth.poll_device_token("d", "https://auth.x.ai/token", interval=1, expires_in=60) + self.assertEqual(result.refresh_token, "r") + self.assertEqual(waits, [6]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_registration_flow.py b/tests/test_registration_flow.py index 3e804dd..5a7ff5a 100644 --- a/tests/test_registration_flow.py +++ b/tests/test_registration_flow.py @@ -155,6 +155,43 @@ class RegistrationFlowTests(unittest.TestCase): self.assertEqual(batch.fail_count, 0) self.assertEqual(batch.postprocess_warning_count, 1) + def test_cleanup_failure_does_not_change_success_statistics(self): + fake = FakeOps() + ops = fake.operations() + base_cleanup = ops.cleanup + def cleanup(reason): + if "已成功" in reason: + raise RuntimeError("cleanup failed") + base_cleanup(reason) + ops.cleanup = cleanup + batch = run_batch(2, self.callbacks(), lambda *args: None, ops, cleanup_interval=1) + self.assertEqual((batch.success_count, batch.fail_count, batch.processed_count), (2, 0, 2)) + + def test_cancel_during_next_account_wait_is_normal_cancellation(self): + fake = FakeOps() + ops = fake.operations() + ops.sleep = lambda seconds: (_ for _ in ()).throw(Cancelled()) + batch = run_batch(2, self.callbacks(), lambda *args: None, ops) + self.assertTrue(batch.cancelled) + self.assertEqual(batch.processed_count, 1) + + def test_final_cleanup_does_not_mask_original_error(self): + fake = FakeOps() + ops = fake.operations() + ops.start_browser = lambda: (_ for _ in ()).throw(ValueError("original")) + ops.cleanup = lambda reason: (_ for _ in ()).throw(RuntimeError("cleanup")) + with self.assertRaisesRegex(ValueError, "original"): + run_batch(1, self.callbacks(), lambda *args: None, ops) + + def test_optional_postprocessing_exceptions_become_warning(self): + fake = FakeOps() + ops = fake.operations() + ops.add_tokens = lambda sso, email: (_ for _ in ()).throw(RuntimeError("pool")) + ops.export_cpa = lambda email, password, sso: (_ for _ in ()).throw(RuntimeError("cpa")) + batch = run_batch(1, self.callbacks(), lambda *args: None, ops) + self.assertEqual(batch.success_count, 1) + self.assertEqual(batch.postprocess_warning_count, 1) + if __name__ == "__main__": unittest.main() diff --git a/tools/apply_full_safe_modularization.py b/tools/apply_full_safe_modularization.py deleted file mode 100644 index 44b03a1..0000000 --- a/tools/apply_full_safe_modularization.py +++ /dev/null @@ -1,1247 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import ast -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -MAIN_PATH = ROOT / "grok_register_ttk.py" - - -def node_span(text: str, node: ast.AST) -> tuple[int, int]: - lines = text.splitlines(keepends=True) - offsets = [0] - for line in lines: - offsets.append(offsets[-1] + len(line)) - start = offsets[node.lineno - 1] + getattr(node, "col_offset", 0) - end_line = getattr(node, "end_lineno", node.lineno) - end_col = getattr(node, "end_col_offset", len(lines[end_line - 1])) - end = offsets[end_line - 1] + end_col - while end < len(text) and text[end] in "\r\n": - end += 1 - return start, end - - -def assignment_names(node: ast.AST) -> set[str]: - names: set[str] = set() - if isinstance(node, (ast.Assign, ast.AnnAssign)): - targets = node.targets if isinstance(node, ast.Assign) else [node.target] - for target in targets: - if isinstance(target, ast.Name): - names.add(target.id) - return names - - -def definition_map(text: str) -> dict[str, ast.AST]: - tree = ast.parse(text) - return { - node.name: node - for node in tree.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - } - - -def assignment_map(text: str) -> dict[str, ast.AST]: - tree = ast.parse(text) - out: dict[str, ast.AST] = {} - for node in tree.body: - for name in assignment_names(node): - out[name] = node - return out - - -def source_for_names(text: str, names: list[str]) -> str: - defs = definition_map(text) - chunks = [] - for name in names: - node = defs.get(name) - if node is None: - raise RuntimeError(f"definition not found: {name}") - start, end = node_span(text, node) - chunks.append(text[start:end].rstrip() + "\n\n") - return "".join(chunks) - - -def source_for_assignments(text: str, names: list[str]) -> str: - amap = assignment_map(text) - chunks = [] - for name in names: - node = amap.get(name) - if node is None: - raise RuntimeError(f"assignment not found: {name}") - start, end = node_span(text, node) - chunks.append(text[start:end].rstrip() + "\n\n") - return "".join(chunks) - - -def remove_top_level(text: str, definition_names=(), assignment_name_set=()) -> str: - tree = ast.parse(text) - spans = [] - for node in tree.body: - remove = False - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - remove = node.name in set(definition_names) - elif assignment_names(node) & set(assignment_name_set): - remove = True - if remove: - spans.append(node_span(text, node)) - for start, end in sorted(spans, reverse=True): - text = text[:start] + text[end:] - return text - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, got {count}") - return text.replace(old, new, 1) - - -main_original = MAIN_PATH.read_text(encoding="utf-8") -main = main_original - -APP_CONFIG_DEFS = [ - "ConfigError", "_require_bool", "_require_int", "_require_string", - "validate_config_structure", "validate_run_requirements", "validate_config", -] -TOKEN_NAMES = [ - "resolve_grok2api_local_token_file", "_normalize_sso_token", - "add_token_to_grok2api_local_pool", "get_grok2api_remote_api_bases", - "add_token_to_grok2api_remote_pool", "add_token_to_grok2api_pools", -] -RUNTIME_NAMES = [ - "get_configured_proxy", "get_proxies", "_parse_proxy_url", "_safe_proxy_port", - "_proxy_has_auth", "_strip_proxy_auth", "_proxy_endpoint_terms", - "is_proxy_connection_error", "page_has_proxy_error", - "_ReusableThreadingTCPServer", "_proxy_recv_until_headers", "_proxy_relay", - "_LocalAuthProxyBridgeHandler", "LocalAuthProxyBridge", "prepare_browser_proxy", - "apply_browser_proxy_option", "create_browser_options", "_build_request_kwargs", - "http_get", "http_post", -] -MAIL_EXACT = { - "get_duckmail_api_key", "get_user_agent", "get_domains", "create_account", - "get_token", "get_messages", "get_message_detail", "generate_username", - "pick_domain", "get_email_provider", "get_email_and_token", "get_oai_code", - "extract_verification_code", "duckmail_get_oai_code", "_pick_list_payload", -} -main_defs = definition_map(main_original) -MAIL_NAMES = sorted( - name for name in main_defs - if name in MAIL_EXACT - or name.startswith("cloudflare_") - or name.startswith("cloudmail_") - or name.startswith("yyds_") - or name.startswith("get_cloudflare_") - or name.startswith("get_cloudmail_") - or name.startswith("get_yyds_") -) -# Exclude non-mail Cloudflare response helpers used for NSFW settings. -MAIL_NAMES = [name for name in MAIL_NAMES if name not in { - "is_cloudflare_block_response", -}] -REGISTRATION_NAMES = [ - "generate_random_birthdate", "response_preview", "is_cloudflare_block_response", - "set_birth_date", "set_tos_accepted", "encode_grpc_nsfw_settings", - "update_nsfw_settings", "enable_nsfw_for_token", "stop_browser_proxy_bridge", - "start_browser", "stop_browser", "restart_browser", "cleanup_runtime_memory", - "refresh_active_page", "click_email_signup_button", "open_signup_page", - "has_profile_form", "fill_email_and_submit", "fill_code_and_submit", - "getTurnstileToken", "build_profile", "fill_profile_and_submit", - "wait_for_sso_cookie", -] - -# --------------------------------------------------------------------------- -# app_config.py: standalone mutable config object and two-stage validation. -# --------------------------------------------------------------------------- -default_config_src = source_for_assignments(main_original, ["DEFAULT_CONFIG"]) -config_error_src = source_for_names(main_original, ["ConfigError"]) -validator_src = source_for_names(main_original, APP_CONFIG_DEFS[1:]) -app_config = f'''"""Application configuration loading, normalization, and validation.""" -import json -import os -import tempfile -import urllib.parse - -CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - -{default_config_src} -config = DEFAULT_CONFIG.copy() - -{config_error_src} -{validator_src} - -def _replace_config(value): - config.clear() - config.update(value) - return config - - -def load_config(): - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as handle: - loaded = json.load(handle) - return _replace_config(validate_config_structure(loaded)) - except ConfigError: - raise - except Exception as exc: - raise ConfigError(f"配置文件解析失败: {{CONFIG_FILE}}: {{exc}}") from exc - return _replace_config(validate_config_structure(DEFAULT_CONFIG.copy())) - - -def save_config(): - normalized = validate_config_structure(config) - _replace_config(normalized) - 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 handle: - fd = None - json.dump(config, handle, indent=4, ensure_ascii=False) - handle.write("\\n") - handle.flush() - os.fsync(handle.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 - return config -''' -ast.parse(app_config) -(ROOT / "app_config.py").write_text(app_config, encoding="utf-8") - -# --------------------------------------------------------------------------- -# browser_runtime.py: single proxy implementation via cpa_xai.proxyutil. -# --------------------------------------------------------------------------- -browser_runtime = r'''"""Shared HTTP, proxy, and Chromium option helpers.""" -import os -import urllib.parse - -from DrissionPage import ChromiumOptions -from curl_cffi import requests -from cpa_xai.proxyutil import ( - LocalAuthProxyBridge, - prepare_chromium_proxy, - proxy_for_chromium, -) - -_config = {} -_extension_path = "" - - -def configure_runtime(config_ref, extension_path=""): - global _config, _extension_path - _config = config_ref - _extension_path = str(extension_path or "") - - -def get_configured_proxy(): - return str(_config.get("proxy", "") or "").strip() - - -def get_proxies(): - proxy = get_configured_proxy() - return {"http": proxy, "https": proxy} if proxy else {} - - -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 = "[%s]" % host - port = _safe_proxy_port(parsed) - netloc = "%s:%s" % (host, port) if port else host - stripped = urllib.parse.urlunsplit((parsed.scheme or "http", netloc, parsed.path, parsed.query, parsed.fragment)) - return stripped.split("://", 1)[1] if "://" not in raw else 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.extend(["%s:%s" % (parsed.hostname, port), "port %s" % port]) - return [item.lower() for item in terms if item] - - -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(item in err for item in ("proxy", "tunnel", "socks")): - return True - markers = ( - "could not connect", "failed to connect", "connection refused", - "connection reset", "connect error", "timed out", "timeout", - ) - if any(item in err for item in markers): - terms = _proxy_endpoint_terms() - return not terms or any(term in err for term in terms) - 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 = "%s\n%s\n%s" % (url, title, body) - text = text.lower() - return any(marker in text for marker in ( - "err_proxy", "proxy connection failed", "proxy server", - "proxy authentication", "tunnel connection failed", - "无法连接到代理服务器", "代理服务器", - )) - - -def prepare_browser_proxy(use_proxy=True, log_callback=None): - proxy = get_configured_proxy() - if not use_proxy or not proxy: - return "", None - parsed = _parse_proxy_url(proxy) - if _proxy_has_auth(proxy) and parsed and (parsed.scheme or "http").lower() not in ("http", "https"): - stripped = _strip_proxy_auth(proxy) - if log_callback: - log_callback("[!] Chromium 暂不直接支持该认证代理协议,已使用去认证代理地址,失败将回退直连") - return stripped, None - logger = None - if log_callback: - logger = lambda message: log_callback("[*] 已为 Chromium启动本地认证代理桥: %s" % message.split(": ", 1)[-1]) if "started authenticated proxy bridge" in message else log_callback(message) - return prepare_chromium_proxy(proxy, log=logger) - - -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("--proxy-server=%s" % 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 _extension_path and 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 is_proxy_connection_error(exc): - direct = dict(request_kwargs) - direct.pop("proxies", None) - return requests.get(url, **direct) - raise - - -def http_post(url, **kwargs): - request_kwargs = _build_request_kwargs(**kwargs) - try: - return requests.post(url, **request_kwargs) - except Exception as exc: - if is_proxy_connection_error(exc): - direct = dict(request_kwargs) - direct.pop("proxies", None) - return requests.post(url, **direct) - raise -''' -ast.parse(browser_runtime) -(ROOT / "browser_runtime.py").write_text(browser_runtime, encoding="utf-8") - -# --------------------------------------------------------------------------- -# account_outputs.py: add token-pool persistence with explicit dependencies. -# --------------------------------------------------------------------------- -account_path = ROOT / "account_outputs.py" -account = account_path.read_text(encoding="utf-8") -if "import time\n" not in account: - account = account.replace("import tempfile\n", "import tempfile\nimport time\n") -token_source = source_for_names(main_original, TOKEN_NAMES) -account += r''' - -# Token-pool runtime dependencies are injected by the application adapter. -config = {} -_http_get = None -_http_post = None -_log_exception = None -_remote_compat_error = RuntimeError -_remote_request_error = RuntimeError - - -def configure_token_runtime(config_ref, http_get, http_post, log_exception, - compatibility_error=RuntimeError, request_error=RuntimeError): - global config, _http_get, _http_post, _log_exception - global _remote_compat_error, _remote_request_error - config = config_ref - _http_get = http_get - _http_post = http_post - _log_exception = log_exception - _remote_compat_error = compatibility_error - _remote_request_error = request_error - globals()["http_get"] = http_get - globals()["http_post"] = http_post - globals()["log_exception"] = log_exception - globals()["RemoteTokenCompatibilityError"] = compatibility_error - globals()["RemoteTokenRequestError"] = request_error - - -''' -account += token_source -ast.parse(account) -account_path.write_text(account, encoding="utf-8") - -# --------------------------------------------------------------------------- -# mail_service.py: move the existing provider implementations unchanged. -# --------------------------------------------------------------------------- -mail_source = source_for_names(main_original, MAIL_NAMES) -constants = source_for_assignments(main_original, ["DUCKMAIL_API_BASE", "YYDS_API_BASE"]) -mail_service = f'''"""Temporary-mail providers shared by GUI, CLI, and debug tooling.""" -import re -import secrets -import string -import time -from typing import Any, Dict, List, Optional, Tuple - -from curl_cffi import requests - -{constants} -config = {{}} -_cf_domain_index = 0 -_cloudmail_domain_index = 0 -_OWN_NAMES = {set(MAIL_NAMES)!r} - - -def bind_runtime(namespace): - global config - config = namespace.get("config", config) - for name, value in namespace.items(): - if name.startswith("__") or name in _OWN_NAMES or name in {{"config", "_cf_domain_index", "_cloudmail_domain_index"}}: - continue - globals()[name] = value - - -{mail_source} - -class CloudflareMailClient: - """Standalone Cloudflare mail client used by the debug CLI.""" - def __init__(self, api_base, auth_mode="none", api_key="", create_path="/api/new_address", timeout=20): - self.api_base = str(api_base or "").rstrip("/") - self.auth_mode = str(auth_mode or "none").lower() - self.api_key = str(api_key or "") - self.create_path = self.normalize_path(create_path, "/api/new_address") - self.timeout = int(timeout) - - @staticmethod - def normalize_path(path, default_path): - raw = (path or default_path).strip() or default_path - return raw if raw.startswith("/") else "/" + raw - - def build_auth_headers(self, content_type=False): - headers = {{"Content-Type": "application/json"}} if content_type else {{}} - if not self.api_key: - return headers - if self.auth_mode == "x-admin-auth": - headers["x-admin-auth"] = self.api_key - elif self.auth_mode == "x-api-key": - headers["X-API-Key"] = self.api_key - elif self.auth_mode == "bearer": - headers["Authorization"] = "Bearer " + self.api_key - return headers - - @staticmethod - def json_or_text(response): - try: - return response.json(), "" - except Exception: - return None, str(getattr(response, "text", "") or "")[:400] - - def create_address(self, domain="", name=""): - is_admin = self.create_path.rstrip("/").lower() == "/admin/new_address" - payload = {{}} - headers = {{"Content-Type": "application/json"}} - if is_admin: - payload = {{"name": name.strip() if str(name).strip() else generate_username(), "enablePrefix": True}} - if str(domain).strip(): - payload["domain"] = str(domain).strip() - headers = self.build_auth_headers(content_type=True) - elif str(domain).strip(): - payload["domain"] = str(domain).strip() - response = requests.post(self.api_base + self.create_path, json=payload, headers=headers, timeout=self.timeout) - response.raise_for_status() - data, raw = self.json_or_text(response) - if not data: - raise RuntimeError("%s 非JSON: %s" % (self.create_path, raw)) - address = str(data.get("address", "")).strip() - jwt = str(data.get("jwt", "")).strip() - if not address or not jwt: - raise RuntimeError("%s 缺少 address/jwt: %r" % (self.create_path, data)) - return address, jwt - - def fetch_box(self, jwt, path, params): - response = requests.get( - self.api_base + path, - params=params, - headers={{"Authorization": "Bearer " + str(jwt)}}, - timeout=self.timeout, - ) - if response.status_code >= 400: - return [] - data, _ = self.json_or_text(response) - return _pick_list_payload(data) - - def probe_all_boxes(self, jwt): - probes = [ - ("/api/mails", {{"limit": 20, "offset": 0}}), - ("/api/sendbox", {{"limit": 20, "offset": 0}}), - ("/api/mails", {{"limit": 20, "offset": 0, "box": "trash"}}), - ("/api/mails", {{"limit": 20, "offset": 0, "folder": "trash"}}), - ("/api/mails", {{"limit": 20, "offset": 0, "deleted": "1"}}), - ("/api/mails", {{"limit": 20, "offset": 0, "status": "deleted"}}), - ] - return [("%s?%s" % (path, params), self.fetch_box(jwt, path, params)) for path, params in probes] - - def get_detail(self, jwt, mail_id): - for path in ("/api/mail/%s" % mail_id, "/api/mails/%s" % mail_id): - try: - response = requests.get( - self.api_base + path, - headers={{"Authorization": "Bearer " + str(jwt)}}, - timeout=self.timeout, - ) - if response.status_code >= 400: - continue - data, _ = self.json_or_text(response) - if isinstance(data, dict): - return data - except Exception: - continue - return {{}} - - @staticmethod - def flatten_mail_text(item, detail): - subject = str(item.get("subject") or detail.get("subject") or "") - parts = [] - for source in (item, detail): - for key in ("text", "raw", "content", "intro", "body", "snippet"): - value = source.get(key) - if isinstance(value, str) and value.strip(): - parts.append(value) - html_value = source.get("html") - if isinstance(html_value, str): - html_value = [html_value] - if isinstance(html_value, list): - parts.extend(re.sub(r"<[^>]+>", " ", item) for item in html_value if isinstance(item, str)) - return subject, "\\n".join(parts) -''' -ast.parse(mail_service) -(ROOT / "mail_service.py").write_text(mail_service, encoding="utf-8") - -# --------------------------------------------------------------------------- -# registration_browser.py: move browser state and page automation unchanged. -# --------------------------------------------------------------------------- -registration_source = source_for_names(main_original, REGISTRATION_NAMES) -registration_browser = f'''"""Registration browser lifecycle and page automation.""" -import gc -import random -import re -import secrets -import struct -import time - -from DrissionPage import Chromium -from DrissionPage.errors import PageDisconnectedError -from curl_cffi import requests - -browser = None -page = None -browser_proxy_bridge = None -browser_started_with_proxy = False -cf_clearance = "" -SIGNUP_URL = "https://accounts.x.ai/sign-up" -_OWN_NAMES = {set(REGISTRATION_NAMES)!r} - - -def bind_runtime(namespace): - for name, value in namespace.items(): - if name.startswith("__") or name in _OWN_NAMES or name in {{ - "browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance", - }}: - continue - globals()[name] = value - - -{registration_source} -''' -ast.parse(registration_browser) -(ROOT / "registration_browser.py").write_text(registration_browser, encoding="utf-8") - -# --------------------------------------------------------------------------- -# Rewrite main as GUI/CLI adapter plus compatibility wrappers. -# --------------------------------------------------------------------------- -main = remove_top_level(main, APP_CONFIG_DEFS, {"CONFIG_FILE", "DEFAULT_CONFIG", "config", "_cf_domain_index", "_cloudmail_domain_index"}) -main = remove_top_level(main, TOKEN_NAMES) -main = remove_top_level(main, RUNTIME_NAMES) -main = remove_top_level(main, MAIL_NAMES, {"DUCKMAIL_API_BASE", "YYDS_API_BASE"}) -main = remove_top_level(main, REGISTRATION_NAMES, {"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance", "SIGNUP_URL"}) - -import_block = '''\nimport functools\nimport app_config as _app_config\nimport account_outputs as _account_outputs\nimport browser_runtime as _browser_runtime\nimport mail_service as _mail_service\nimport registration_browser as _registration_browser\nfrom app_config import (\n CONFIG_FILE, DEFAULT_CONFIG, ConfigError, config, load_config, save_config,\n validate_config, validate_config_structure, validate_run_requirements,\n)\n\n''' -main = replace_once(main, "from curl_cffi import requests\n", "from curl_cffi import requests\n" + import_block, "main imports") - -compat_block = f'''\ndef _make_compat_proxy(module, name, binder=None): - target = getattr(module, name) - @functools.wraps(target) - def proxy(*args, **kwargs): - if binder is not None: - binder() - return getattr(module, name)(*args, **kwargs) - return proxy - - -def _bind_browser_runtime(): - _browser_runtime.configure_runtime(config, EXTENSION_PATH) - - -def _bind_account_outputs(): - _account_outputs.configure_token_runtime( - config, http_get, http_post, log_exception, - compatibility_error=RemoteTokenCompatibilityError, - request_error=RemoteTokenRequestError, - ) - - -def _bind_mail_service(): - _mail_service.bind_runtime(globals()) - - -def _bind_registration_browser(): - _registration_browser.bind_runtime(globals()) - - -LocalAuthProxyBridge = _browser_runtime.LocalAuthProxyBridge -for _name in {RUNTIME_NAMES!r}: - if _name.startswith("_") and _name in {{"_ReusableThreadingTCPServer", "_LocalAuthProxyBridgeHandler", "_proxy_recv_until_headers", "_proxy_relay"}}: - continue - if _name != "LocalAuthProxyBridge": - globals()[_name] = _make_compat_proxy(_browser_runtime, _name, _bind_browser_runtime) -for _name in {TOKEN_NAMES!r}: - globals()[_name] = _make_compat_proxy(_account_outputs, _name, _bind_account_outputs) -for _name in {MAIL_NAMES!r}: - globals()[_name] = _make_compat_proxy(_mail_service, _name, _bind_mail_service) -for _name in {REGISTRATION_NAMES!r}: - globals()[_name] = _make_compat_proxy(_registration_browser, _name, _bind_registration_browser) - - -def __getattr__(name): - if name in {{"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance"}}: - return getattr(_registration_browser, name) - raise AttributeError(name) - - -''' -main = replace_once(main, "def raise_if_cancelled(cancel_callback=None):", compat_block + "def raise_if_cancelled(cancel_callback=None):", "compat block") -main = main.replace("current_page = page", "current_page = _registration_browser.page") -main = main.replace("browser_missing=lambda: browser is None", "browser_missing=lambda: _registration_browser.browser is None") -ast.parse(main) -MAIN_PATH.write_text(main, encoding="utf-8") - -# --------------------------------------------------------------------------- -# CPA browser lifecycle extraction. -# --------------------------------------------------------------------------- -browser_confirm_path = ROOT / "cpa_xai" / "browser_confirm.py" -browser_confirm_original = browser_confirm_path.read_text(encoding="utf-8") -SESSION_NAMES = [ - "_noop_log", "BrowserConfirmError", "_sleep", "create_standalone_page", - "close_standalone", "_register_mint_browser", "_unregister_mint_browser", - "_mint_tls_get", "clear_page_session", "normalize_cookies", "inject_cookies", - "acquire_mint_browser", "release_mint_browser", "shutdown_mint_browsers", -] -session_source = source_for_names(browser_confirm_original, SESSION_NAMES) -browser_session = '''"""CPA Chromium session lifecycle, reuse, cleanup, and cookies."""\nfrom __future__ import annotations\n\nimport os\nimport sys\nimport threading\nimport time\nfrom pathlib import Path\nfrom typing import Any, Callable, Optional\n\nLogFn = Callable[[str], None]\n_mint_tls = threading.local()\n_mint_registry_lock = threading.Lock()\n_mint_registry = set()\n\n''' + session_source -ast.parse(browser_session) -(ROOT / "cpa_xai" / "browser_session.py").write_text(browser_session, encoding="utf-8") - -browser_confirm = remove_top_level(browser_confirm_original, SESSION_NAMES, {"_mint_tls", "_mint_registry_lock", "_mint_registry"}) -session_import = '''from .browser_session import (\n BrowserConfirmError, _noop_log, _sleep, create_standalone_page, close_standalone,\n clear_page_session, normalize_cookies, inject_cookies, acquire_mint_browser,\n release_mint_browser, shutdown_mint_browsers,\n)\n\n''' -browser_confirm = replace_once(browser_confirm, "LogFn = Callable[[str], None]\n", "LogFn = Callable[[str], None]\n\n" + session_import, "browser session import") -ast.parse(browser_confirm) -browser_confirm_path.write_text(browser_confirm, encoding="utf-8") - -# --------------------------------------------------------------------------- -# cf_mail_debug.py becomes a thin CLI around CloudflareMailClient. -# --------------------------------------------------------------------------- -cf_debug = r'''#!/usr/bin/env python -# -*- coding: utf-8 -*- -import argparse -import time - -from mail_service import CloudflareMailClient, extract_verification_code - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--api-base", required=True) - parser.add_argument("--address", default="") - parser.add_argument("--credential", default="") - parser.add_argument("--auth-mode", default="none", choices=["none", "bearer", "x-api-key", "x-admin-auth"]) - parser.add_argument("--api-key", default="") - parser.add_argument("--create-path", default="/api/new_address") - parser.add_argument("--domain", default="") - parser.add_argument("--name", default="") - parser.add_argument("--timeout", type=int, default=180) - parser.add_argument("--interval", type=int, default=3) - args = parser.parse_args() - - client = CloudflareMailClient( - args.api_base, - auth_mode=args.auth_mode, - api_key=args.api_key, - create_path=args.create_path, - ) - address = args.address.strip() - credential = args.credential.strip() - if not credential: - address, credential = client.create_address(domain=args.domain, name=args.name) - print("[NEW] address=%s" % address) - print("[NEW] credential(jwt)=%s" % credential) - else: - print("[USE] address=%s" % (address or "(unknown, from credential)")) - - deadline = time.time() + max(args.timeout, 1) - seen_ids = set() - while time.time() < deadline: - boxes = client.probe_all_boxes(credential) - total = 0 - for box_name, mails in boxes: - if mails: - print("[BOX] %s -> %s" % (box_name, len(mails))) - total += len(mails) - for item in mails: - mail_id = item.get("id") or item.get("mail_id") - if not mail_id or mail_id in seen_ids: - continue - seen_ids.add(mail_id) - detail = client.get_detail(credential, mail_id) - subject, text = client.flatten_mail_text(item, detail) - code = extract_verification_code(text, subject) - print("[MAIL] id=%s subject=%r code=%r" % (mail_id, subject, code)) - if code: - print("[FOUND] %s" % code) - return - if total == 0: - print("[INFO] no mails yet") - time.sleep(max(args.interval, 1)) - print("[TIMEOUT] no code found") - - -if __name__ == "__main__": - main() -''' -ast.parse(cf_debug) -(ROOT / "cf_mail_debug.py").write_text(cf_debug, encoding="utf-8") - -# --------------------------------------------------------------------------- -# cpa_export.py settings object and import without sys.path mutation. -# --------------------------------------------------------------------------- -cpa_export = r'''"""Optional post-registration CPA/OIDC export hook.""" -from dataclasses import dataclass -import importlib.util -import os -import shutil -import time -from pathlib import Path - -_ROOT = Path(__file__).resolve().parent -_DEFAULT_AUTH_DIR = _ROOT / "cpa_auths" - - -@dataclass(frozen=True) -class CpaExportSettings: - enabled: bool - auth_dir: Path - hotload_dir: Path | None - copy_to_hotload: bool - proxy: str - headless: bool - mint_timeout: float - request_timeout: float - poll_timeout: float - base_url: str - force_standalone: bool - cookie_inject: bool - tools_dir: str - - @classmethod - def from_config(cls, config): - cfg = dict(config or {}) - auth_dir = Path(cfg.get("cpa_auth_dir") or _DEFAULT_AUTH_DIR).expanduser() - if not auth_dir.is_absolute(): - auth_dir = (_ROOT / auth_dir).resolve() - hotload_value = str(cfg.get("cpa_hotload_dir") or "").strip() - hotload_dir = Path(hotload_value).expanduser() if hotload_value else None - if hotload_dir is not None and not hotload_dir.is_absolute(): - hotload_dir = (_ROOT / hotload_dir).resolve() - return cls( - enabled=bool(cfg.get("cpa_export_enabled", False)), - auth_dir=auth_dir, - hotload_dir=hotload_dir, - copy_to_hotload=bool(cfg.get("cpa_copy_to_hotload", False)), - proxy=str(cfg.get("cpa_proxy") or cfg.get("proxy") or "").strip(), - headless=bool(cfg.get("cpa_headless", False)), - mint_timeout=float(cfg.get("cpa_mint_timeout_sec") or 300), - request_timeout=float(cfg.get("cpa_oidc_request_timeout_sec") or 15), - poll_timeout=float(cfg.get("cpa_oidc_poll_timeout_sec") or 15), - base_url=str(cfg.get("cpa_base_url") or "https://cli-chat-proxy.grok.com/v1").strip(), - force_standalone=bool(cfg.get("cpa_force_standalone", True)), - cookie_inject=bool(cfg.get("cpa_mint_cookie_inject", True)), - tools_dir=str(cfg.get("api_reverse_tools") or "").strip(), - ) - - -def _load_mint_and_export(tools_dir=""): - tools_value = str(tools_dir or "").strip() - if not tools_value: - from cpa_xai import mint_and_export - return mint_and_export - tools = Path(tools_value).expanduser().resolve() - package = tools if tools.name == "cpa_xai" else tools / "cpa_xai" - init_path = package / "__init__.py" - if package.resolve() == (_ROOT / "cpa_xai").resolve(): - from cpa_xai import mint_and_export - return mint_and_export - if not init_path.is_file(): - raise ImportError("cpa_xai package not found under %s" % tools) - module_name = "_external_cpa_xai_%s" % abs(hash(str(package))) - spec = importlib.util.spec_from_file_location( - module_name, - str(init_path), - submodule_search_locations=[str(package)], - ) - if spec is None or spec.loader is None: - raise ImportError("unable to load cpa_xai from %s" % package) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.mint_and_export - - -def export_cookies_from_page(page): - if page is None: - return [] - cookies = None - for getter in ( - lambda: page.cookies(all_domains=True, all_info=True), - lambda: page.cookies(all_domains=True), - lambda: page.cookies(), - ): - try: - cookies = getter() - if cookies: - break - except TypeError: - continue - except Exception: - continue - if not cookies: - try: - browser = getattr(page, "browser", None) - if browser is not None: - cookies = browser.cookies() - except Exception: - cookies = None - return [item for item in cookies if isinstance(item, dict)] if isinstance(cookies, list) else [] - - -def _normalize_result(result, email=""): - value = dict(result or {}) - value.setdefault("ok", False) - value.setdefault("skipped", False) - value.setdefault("email", str(email or "")) - value.setdefault("error", None) - return value - - -def export_cpa_xai_for_account(email, password, page=None, cookies=None, sso=None, - config=None, log_callback=None, cancel_callback=None): - settings = CpaExportSettings.from_config(config) - log = log_callback or (lambda message: None) - if not settings.enabled: - return _normalize_result({"ok": False, "skipped": True, "reason": "disabled"}, email) - try: - mint_and_export = _load_mint_and_export(settings.tools_dir) - except Exception as exc: - log("[cpa] import cpa_xai failed: %s" % exc) - return _normalize_result({"ok": False, "error": "import: %s" % exc}, email) - - use_cookies = cookies - if use_cookies is None and settings.cookie_inject and page is not None: - use_cookies = export_cookies_from_page(page) - if not settings.cookie_inject: - use_cookies = None - elif sso: - base = list(use_cookies) if isinstance(use_cookies, list) else [] - sso_value = str(sso).strip() - for cookie_name in ("sso", "sso-rw"): - for domain in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai", "grok.com", ".grok.com"): - base.append({"name": cookie_name, "value": sso_value, "domain": domain, - "path": "/", "secure": True, "httpOnly": True}) - use_cookies = base - - settings.auth_dir.mkdir(parents=True, exist_ok=True) - log("[cpa] mint OIDC for %s -> %s" % (email, settings.auth_dir)) - result = mint_and_export( - email=email, password=password, auth_dir=settings.auth_dir, - page=None if settings.force_standalone else page, - proxy=settings.proxy or None, headless=settings.headless, - base_url=settings.base_url, browser_timeout_sec=settings.mint_timeout, - force_standalone=settings.force_standalone, cookies=use_cookies, - reuse_browser=True, recycle_every=15, - log=lambda message: log("[cpa] %s" % message), cancel=cancel_callback, - request_timeout_sec=settings.request_timeout, - poll_timeout_sec=settings.poll_timeout, - ) - result = _normalize_result(result, email) - if result.get("ok") and result.get("path") and settings.copy_to_hotload and settings.hotload_dir: - try: - settings.hotload_dir.mkdir(parents=True, exist_ok=True) - source = Path(result["path"]) - target = settings.hotload_dir / source.name - shutil.copy2(str(source), str(target)) - try: - os.chmod(str(target), 0o600) - except Exception: - pass - result["hotload_path"] = str(target) - log("[cpa] hotload copy -> %s" % target) - except Exception as exc: - result["cpa_copy_error"] = str(exc) - log("[cpa] hotload copy failed: %s" % exc) - if not result.get("ok"): - fail_path = settings.auth_dir / "cpa_auth_failed.txt" - try: - with open(str(fail_path), "a", encoding="utf-8") as handle: - handle.write("%s----%s----%s\n" % (email, result.get("error") or "unknown", int(time.time()))) - except Exception as exc: - log("[cpa] failed to persist failure record: %s" % exc) - return result -''' -ast.parse(cpa_export) -(ROOT / "cpa_export.py").write_text(cpa_export, encoding="utf-8") - -# --------------------------------------------------------------------------- -# Additional regression tests. -# --------------------------------------------------------------------------- -compat_tests = f'''import unittest -from unittest.mock import patch - -import app_config -import grok_register_ttk as app -import mail_service -import registration_browser -from cpa_xai import browser_confirm -from cpa_xai import browser_session - - -class ModuleCompatibilityTests(unittest.TestCase): - def test_config_object_is_shared(self): - self.assertIs(app.config, app_config.config) - - def test_original_public_functions_remain_available(self): - names = {sorted(set(MAIL_NAMES + REGISTRATION_NAMES + TOKEN_NAMES + ["http_get", "http_post", "create_browser_options"]))!r} - for name in names: - self.assertTrue(callable(getattr(app, name)), name) - - def test_mail_wrapper_delegates(self): - with patch.object(mail_service, "get_email_provider", return_value="duckmail") as mocked: - self.assertEqual(app.get_email_provider(), "duckmail") - mocked.assert_called_once_with() - - def test_browser_confirm_reexports_session_api(self): - self.assertIs(browser_confirm.close_standalone, browser_session.close_standalone) - self.assertIs(browser_confirm.normalize_cookies, browser_session.normalize_cookies) - - -if __name__ == "__main__": - unittest.main() -''' -(ROOT / "tests" / "test_module_compatibility.py").write_text(compat_tests, encoding="utf-8") - -flow_test_path = ROOT / "tests" / "test_registration_flow.py" -flow_tests = flow_test_path.read_text(encoding="utf-8") -extra_flow = r''' - def test_cleanup_failure_does_not_change_success_statistics(self): - fake = FakeOps() - ops = fake.operations() - base_cleanup = ops.cleanup - def cleanup(reason): - if "已成功" in reason: - raise RuntimeError("cleanup failed") - base_cleanup(reason) - ops.cleanup = cleanup - batch = run_batch(2, self.callbacks(), lambda *args: None, ops, cleanup_interval=1) - self.assertEqual((batch.success_count, batch.fail_count, batch.processed_count), (2, 0, 2)) - - def test_cancel_during_next_account_wait_is_normal_cancellation(self): - fake = FakeOps() - ops = fake.operations() - ops.sleep = lambda seconds: (_ for _ in ()).throw(Cancelled()) - batch = run_batch(2, self.callbacks(), lambda *args: None, ops) - self.assertTrue(batch.cancelled) - self.assertEqual(batch.processed_count, 1) - - def test_final_cleanup_does_not_mask_original_error(self): - fake = FakeOps() - ops = fake.operations() - ops.start_browser = lambda: (_ for _ in ()).throw(ValueError("original")) - ops.cleanup = lambda reason: (_ for _ in ()).throw(RuntimeError("cleanup")) - with self.assertRaisesRegex(ValueError, "original"): - run_batch(1, self.callbacks(), lambda *args: None, ops) - - def test_optional_postprocessing_exceptions_become_warning(self): - fake = FakeOps() - ops = fake.operations() - ops.add_tokens = lambda sso, email: (_ for _ in ()).throw(RuntimeError("pool")) - ops.export_cpa = lambda email, password, sso: (_ for _ in ()).throw(RuntimeError("cpa")) - batch = run_batch(1, self.callbacks(), lambda *args: None, ops) - self.assertEqual(batch.success_count, 1) - self.assertEqual(batch.postprocess_warning_count, 1) -''' -flow_tests = replace_once(flow_tests, '\n\nif __name__ == "__main__":\n', extra_flow + '\n\nif __name__ == "__main__":\n', "flow tests") -flow_test_path.write_text(flow_tests, encoding="utf-8") - -oauth_tests = r'''import json -import unittest -from unittest.mock import patch - -from cpa_xai import oauth_device as oauth - - -class Response: - def __init__(self, body, status=200): - self.body = body.encode("utf-8") - self.status = status - def __enter__(self): - return self - def __exit__(self, *args): - return False - def read(self): - return self.body - - -class Opener: - def __init__(self, actions): - self.actions = list(actions) - self.calls = 0 - def open(self, request, timeout=None): - self.calls += 1 - action = self.actions.pop(0) - if isinstance(action, BaseException): - raise action - return action - - -class OAuthDeviceTests(unittest.TestCase): - def test_discovery_success(self): - payload = {"device_authorization_endpoint": "https://auth.x.ai/device", "token_endpoint": "https://auth.x.ai/token"} - opener = Opener([Response(json.dumps(payload))]) - with patch.object(oauth, "_build_opener", return_value=opener): - self.assertEqual(oauth.discover(retries=0)["token_endpoint"], payload["token_endpoint"]) - - def test_discovery_cancelled_before_request(self): - with self.assertRaisesRegex(oauth.OAuthDeviceError, "cancelled"): - oauth.discover(cancel=lambda: True) - - def test_discovery_retries_transient_error(self): - payload = {"device_authorization_endpoint": "https://auth.x.ai/device", "token_endpoint": "https://auth.x.ai/token"} - opener = Opener([TimeoutError("slow"), Response(json.dumps(payload))]) - with patch.object(oauth, "_build_opener", return_value=opener), patch.object(oauth, "_sleep_with_cancel"): - oauth.discover(retries=1) - self.assertEqual(opener.calls, 2) - - def test_post_form_returns_non_json_body(self): - opener = Opener([Response("not-json", status=502)]) - with patch.object(oauth, "_build_opener", return_value=opener): - status, payload = oauth._post_form("https://auth.x.ai/token", {}, retries=0) - self.assertEqual((status, payload), (502, "not-json")) - - def test_slow_down_increases_wait(self): - responses = [ - (400, {"error": "slow_down"}), - (200, {"access_token": "a", "refresh_token": "r"}), - ] - waits = [] - with patch.object(oauth, "_post_form", side_effect=responses), patch.object(oauth, "_sleep_with_cancel", side_effect=lambda seconds, cancel=None: waits.append(seconds)): - result = oauth.poll_device_token("d", "https://auth.x.ai/token", interval=1, expires_in=60) - self.assertEqual(result.refresh_token, "r") - self.assertEqual(waits, [6]) - - -if __name__ == "__main__": - unittest.main() -''' -(ROOT / "tests" / "test_oauth_device.py").write_text(oauth_tests, encoding="utf-8") - -browser_session_tests = r'''import unittest -from cpa_xai import browser_session - - -class Bridge: - def __init__(self): - self.stops = 0 - def stop(self): - self.stops += 1 - - -class Browser: - def __init__(self, bridge=None): - self._cpa_proxy_bridge = bridge - self.quits = 0 - def quit(self): - self.quits += 1 - - -class BrowserSessionTests(unittest.TestCase): - def test_close_standalone_closes_browser_and_bridge(self): - bridge = Bridge() - browser = Browser(bridge) - browser_session._register_mint_browser(browser) - browser_session.close_standalone(browser) - self.assertEqual(browser.quits, 1) - self.assertEqual(bridge.stops, 1) - - def test_normalize_cookies_rejects_invalid_items(self): - value = browser_session.normalize_cookies([None, {"name": "a", "value": "b"}, "bad"]) - self.assertEqual(len(value), 1) - self.assertEqual(value[0]["name"], "a") - - -if __name__ == "__main__": - unittest.main() -''' -(ROOT / "tests" / "test_browser_session.py").write_text(browser_session_tests, encoding="utf-8") - -core_tests = r'''import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from cpa_xai.mint import mint_and_export -from cpa_xai.schema import build_cpa_xai_auth, jwt_payload -from cpa_xai.writer import write_cpa_xai_auth - - -class CpaCoreTests(unittest.TestCase): - def test_schema_rejects_missing_tokens(self): - with self.assertRaises(ValueError): - build_cpa_xai_auth("a@example.com", "", "refresh") - with self.assertRaises(ValueError): - jwt_payload("not-a-jwt") - - def test_writer_failure_does_not_leave_temp_file(self): - with tempfile.TemporaryDirectory() as directory: - with patch("cpa_xai.writer.os.replace", side_effect=OSError("disk")): - with self.assertRaises(OSError): - write_cpa_xai_auth(directory, {"email": "a@example.com"}, "a.json") - self.assertEqual([p.name for p in Path(directory).iterdir()], []) - - def test_mint_rejects_missing_identity_without_browser(self): - result = mint_and_export("", "", tempfile.gettempdir()) - self.assertFalse(result["ok"]) - self.assertIn("missing", result["error"]) - - -if __name__ == "__main__": - unittest.main() -''' -(ROOT / "tests" / "test_cpa_core.py").write_text(core_tests, encoding="utf-8") - -# Syntax validation for every touched Python file. -for path in [ - MAIN_PATH, ROOT / "app_config.py", ROOT / "account_outputs.py", - ROOT / "browser_runtime.py", ROOT / "mail_service.py", - ROOT / "registration_browser.py", browser_confirm_path, - ROOT / "cpa_xai" / "browser_session.py", ROOT / "cf_mail_debug.py", - ROOT / "cpa_export.py", -] + list((ROOT / "tests").glob("test_*.py")): - ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - -print("full safe modularization applied") diff --git a/tools/build_refactor_inventory.py b/tools/build_refactor_inventory.py deleted file mode 100644 index 22e38f5..0000000 --- a/tools/build_refactor_inventory.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -import ast -import json -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -TARGETS = [ - ROOT / "grok_register_ttk.py", - ROOT / "registration_flow.py", - ROOT / "cpa_xai" / "browser_confirm.py", - ROOT / "cpa_export.py", - ROOT / "cf_mail_debug.py", - ROOT / "cpa_xai" / "oauth_device.py", - ROOT / "cpa_xai" / "proxyutil.py", -] - - -def free_names(node): - assigned = set() - loaded = set() - for child in ast.walk(node): - if isinstance(child, ast.Name): - if isinstance(child.ctx, (ast.Store, ast.Param)): - assigned.add(child.id) - elif isinstance(child.ctx, ast.Load): - loaded.add(child.id) - elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and child is not node: - assigned.add(child.name) - return sorted(loaded - assigned) - -out = {} -for path in TARGETS: - if not path.exists(): - continue - text = path.read_text(encoding="utf-8") - tree = ast.parse(text, filename=str(path)) - entries = [] - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - entries.append({ - "kind": type(node).__name__, - "name": node.name, - "start": node.lineno, - "end": getattr(node, "end_lineno", node.lineno), - "free_names": free_names(node), - }) - out[str(path.relative_to(ROOT))] = { - "lines": len(text.splitlines()), - "definitions": entries, - } - -(ROOT / "refactor_inventory.json").write_text( - json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" -) -print("inventory written") diff --git a/tools/fix_modularization_ast_spans.py b/tools/fix_modularization_ast_spans.py deleted file mode 100644 index b4dbd23..0000000 --- a/tools/fix_modularization_ast_spans.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -path = Path(__file__).resolve().parent / "apply_full_safe_modularization.py" -text = path.read_text(encoding="utf-8") -old = '''def node_span(text: str, node: ast.AST) -> tuple[int, int]: - lines = text.splitlines(keepends=True) - offsets = [0] - for line in lines: - offsets.append(offsets[-1] + len(line)) - start = offsets[node.lineno - 1] + getattr(node, "col_offset", 0) - end_line = getattr(node, "end_lineno", node.lineno) - end_col = getattr(node, "end_col_offset", len(lines[end_line - 1])) - end = offsets[end_line - 1] + end_col - while end < len(text) and text[end] in "\\r\\n": - end += 1 - return start, end -''' -new = '''def node_span(text: str, node: ast.AST) -> tuple[int, int]: - # AST column offsets are UTF-8 byte offsets, not Python character offsets. - # Every node moved by this script is top-level, so complete source lines are - # the safest exact boundary and preserve non-ASCII strings without truncation. - lines = text.splitlines(keepends=True) - offsets = [0] - for line in lines: - offsets.append(offsets[-1] + len(line)) - start = offsets[node.lineno - 1] - end_line = getattr(node, "end_lineno", node.lineno) - end = offsets[end_line] - return start, end -''' -if text.count(old) != 1: - raise RuntimeError(f"node_span patch expected one match, got {text.count(old)}") -path.write_text(text.replace(old, new, 1), encoding="utf-8") -print("fixed AST source spans") diff --git a/tools/fix_modularization_public_compat.py b/tools/fix_modularization_public_compat.py deleted file mode 100644 index 99ea8b6..0000000 --- a/tools/fix_modularization_public_compat.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -path = Path(__file__).resolve().with_name("apply_full_safe_modularization.py") -text = path.read_text(encoding="utf-8") - - -def replace_once(source, old, new, label): - count = source.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, got {count}") - return source.replace(old, new, 1) - - -# Keep monkeypatch compatibility for generate_username after moving mail code. -text = replace_once( - text, - '''def _bind_mail_service(): - _mail_service.bind_runtime(globals()) -''', - '''def _bind_mail_service(): - _mail_service.bind_runtime(globals()) - _current = globals().get("generate_username") - _standard = _MAIL_COMPAT_PROXIES.get("generate_username") - if _current is not None and _current is not _standard: - _mail_service.generate_username = _current - elif _standard is not None: - _mail_service.generate_username = _MAIL_ORIGINALS["generate_username"] -''', - "mail binder", -) - -text = replace_once( - text, - '''for _name in {MAIL_NAMES!r}: - globals()[_name] = _make_compat_proxy(_mail_service, _name, _bind_mail_service) -for _name in {REGISTRATION_NAMES!r}: -''', - '''_MAIL_ORIGINALS = dict((name, getattr(_mail_service, name)) for name in {MAIL_NAMES!r}) -_MAIL_COMPAT_PROXIES = dict() -for _name in {MAIL_NAMES!r}: - _proxy = _make_compat_proxy(_mail_service, _name, _bind_mail_service) - _MAIL_COMPAT_PROXIES[_name] = _proxy - globals()[_name] = _proxy -for _name in {REGISTRATION_NAMES!r}: -''', - "mail proxy registry", -) - -# Preserve cf_mail_debug.requests and create_address for tests/external callers. -text = replace_once( - text, - '''import argparse -import time - -from mail_service import CloudflareMailClient, extract_verification_code - - -def main(): -''', - '''import argparse -import time - -from curl_cffi import requests -from mail_service import CloudflareMailClient, extract_verification_code - - -def create_address(api_base, auth_mode="none", api_key="", create_path="/api/new_address", - domain="", name="", timeout=20): - import mail_service as _mail_service - client = CloudflareMailClient( - api_base, auth_mode=auth_mode, api_key=api_key, - create_path=create_path, timeout=timeout, - ) - original_requests = _mail_service.requests - _mail_service.requests = requests - try: - return client.create_address(domain=domain, name=name) - finally: - _mail_service.requests = original_requests - - -def main(): -''', - "debug public API", -) - -text = replace_once( - text, - ''' address, credential = client.create_address(domain=args.domain, name=args.name) -''', - ''' address, credential = create_address( - args.api_base, - auth_mode=args.auth_mode, - api_key=args.api_key, - create_path=args.create_path, - domain=args.domain, - name=args.name, - ) -''', - "debug create call", -) - -# External cpa_xai packages may use relative imports; register before execution. -text = replace_once( - text, - "import shutil\nimport time\nfrom pathlib import Path\n", - "import shutil\nimport sys\nimport time\nfrom pathlib import Path\n", - "cpa sys import", -) -text = replace_once( - text, - ''' module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.mint_and_export -''', - ''' module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - try: - spec.loader.exec_module(module) - except Exception: - sys.modules.pop(module_name, None) - raise - return module.mint_and_export -''', - "cpa external package load", -) - -path.write_text(text, encoding="utf-8") -print("public compatibility patch applied") diff --git a/tools/fix_modularization_shared_state.py b/tools/fix_modularization_shared_state.py deleted file mode 100644 index 9a81d7a..0000000 --- a/tools/fix_modularization_shared_state.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -path = Path(__file__).resolve().with_name("apply_full_safe_modularization.py") -text = path.read_text(encoding="utf-8") - - -def replace_once(source, old, new, label): - count = source.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, got {count}") - return source.replace(old, new, 1) - - -# Patch the generated main-module import block using a small stable anchor. -text = replace_once( - text, - "import_block = '''\\nimport functools\\n", - "import_block = '''\\nimport functools\\nimport types\\n", - "main import block", -) - -old_getattr = '''def __getattr__(name): - if name in {{"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance"}}: - return getattr(_registration_browser, name) - raise AttributeError(name) -''' -new_getattr = '''def __getattr__(name): - if name in {{"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance"}}: - return getattr(_registration_browser, name) - if name in {{"_cf_domain_index", "_cloudmail_domain_index"}}: - return getattr(_mail_service, name) - raise AttributeError(name) - - -class _CompatibilityModule(types.ModuleType): - def __setattr__(self, name, value): - if name == "config": - if value is not _app_config.config: - if not isinstance(value, dict): - raise TypeError("config must be a dict") - _app_config.config.clear() - _app_config.config.update(value) - value = _app_config.config - elif name in {{"_cf_domain_index", "_cloudmail_domain_index"}}: - setattr(_mail_service, name, int(value)) - self.__dict__.pop(name, None) - return - super().__setattr__(name, value) - - -sys.modules[__name__].__class__ = _CompatibilityModule -''' -text = replace_once(text, old_getattr, new_getattr, "compatibility getattr") - -path.write_text(text, encoding="utf-8") -print("shared state compatibility patch applied")