commit 3c5e86c35d8bbbf42303a2ba3a90c95282309189 Author: Git-creat7 <3516988334@qq.com> Date: Sat Jul 11 06:45:02 2026 -0500 feat: Grok 注册机 — 注册即入库 CLIProxyAPI (CPA) - GUI / CLI 批量注册 Grok 账号 - SSO → device-flow OAuth → 本地 auth 目录或远程 Management API - Cloudflare / DuckMail / YYDS 临时邮箱 - Ctrl+C 安全停止,清理阶段不刷 traceback diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0c5bb01 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.py text eol=lf +*.md text eol=lf +*.json text eol=lf +*.txt text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..395d0fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.venv/ +venv/ +env/ + +accounts_*.txt +mail_credentials.txt +*.txt +!requirements.txt + +config.json +config.local.json +config.local.* +*.bak + +# 真实账号凭证 / OAuth token 输出,禁止提交 +auth_out.json +auth_out/ +auth_merged.json +*.auth.json +sso_list.txt +token.json + +*.log +.DS_Store +.idea/ +.vscode/ +DrissionPage/ +autoPortData/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ea2013 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AaronL725 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c87ee46 --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +
+ +[![Grok Register — 注册即入库 CLIProxyAPI](assets/banner.png)](https://github.com/Git-creat7/grokRegister-cpa) + +批量注册 Grok 账号,注册成功后自动把 OAuth 凭证写入 [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI):支持本地 auth 目录热加载,也支持 Management API 远程上传。 + +

+ License: MIT + Python 3.9+ + GUI + CLI + CLIProxyAPI +

+ +
+ +--- + +> 仅用于自动化流程研究、测试环境验证和个人学习。请遵守目标网站服务条款、当地法律法规与第三方服务限制。 + +## 核心流程 + +```text +打开注册页 → 创建临时邮箱 → 收验证码 → 填资料 / 过人机验证 + → 拿到 SSO cookie → device-flow 换 OAuth token + → 本地写入 cpa_auth_dir 和/或 POST 远程 CPA Management API + → CPA 热加载,立即可用 +``` + +## 功能 + +- 注册成功后自动入库 CPA(本地目录 / 远程 Management API,可同时开) +- GUI + CLI 两种运行方式(CLI 仍会打开浏览器完成注册页) +- Chromium/Chrome 自动处理 Turnstile +- DuckMail / YYDS / Cloudflare 临时邮箱 +- 注册后可选开启 NSFW +- 页面卡住重试、验证码失败换邮箱、浏览器重启与内存清理 +- CLI:一次 `Ctrl+C` 安全停止,清理阶段不刷 traceback;再按一次强制中断 + +## 环境要求 + +- Python 3.9+ +- Google Chrome 或 Chromium +- 可用的 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) +- 能访问注册页、临时邮箱 API、`auth.x.ai` 的网络(device-flow 换 token 需要) + +## 安装 + +```bash +git clone https://github.com/Git-creat7/grokRegister-cpa.git +cd grokRegister-cpa +pip install -r requirements.txt +cp config.example.json config.json +``` + +编辑 `config.json` 后运行。 + +## 配置 + +| 配置项 | 说明 | +| --- | --- | +| `cpa_auto_add` | 是否开启 CPA 自动入库 | +| `cpa_auth_dir` | 本地 CPA auth 目录;写入 `xai-.json`,可留空 | +| `cpa_remote_url` | 远程 CPA 地址,如 `http://127.0.0.1:8317` | +| `cpa_management_key` | 远程 CPA 管理密钥(`remote-management.secret-key` 明文) | +| `email_provider` | `duckmail` / `yyds` / `cloudflare` | +| `register_count` | 目标注册数量 | +| `proxy` | 代理;device-flow 换 token 也走此代理 | +| `enable_nsfw` | 注册后是否尝试开启 NSFW | +| `cloudflare_api_base` | Cloudflare 临时邮箱 API 根地址 | +| `cloudflare_api_key` | 默认匿名模式留空;admin 模式填 `ADMIN_PASSWORD` | +| `cloudflare_auth_mode` | `none` / `bearer` / `x-api-key` / `x-admin-auth` / `query-key` | +| `cloudflare_custom_auth` | Worker 全局密码(`PASSWORDS`),注入 `x-custom-auth` | +| `cloudflare_path_*` | domains / accounts / token / messages 路径 | +| `defaultDomains` | Cloudflare 默认收信域名 | + +### Cloudflare 邮箱(默认匿名) + +```json +{ + "email_provider": "cloudflare", + "cloudflare_api_base": "https://你的-worker-api-域名", + "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", + "defaultDomains": "你的收信域名.com" +} +``` + +匿名创建失败(例如 Turnstile)时可改 admin 创建: + +```json +{ + "cloudflare_api_key": "你的 ADMIN_PASSWORD", + "cloudflare_auth_mode": "x-admin-auth", + "cloudflare_path_accounts": "/admin/new_address" +} +``` + +调试创建接口: + +```bash +python cf_mail_debug.py \ + --api-base "https://你的-worker-api-域名" \ + --auth-mode x-admin-auth \ + --api-key "你的 ADMIN_PASSWORD" \ + --create-path /admin/new_address \ + --domain "你的收信域名.com" +``` + +Worker 若配置了全局 `PASSWORDS`,再加: + +```json +{ "cloudflare_custom_auth": "你的全局访问密码" } +``` + +## CPA 自动入库 + +SSO 不是 CPA 凭据。程序会: + +1. 用 SSO 走 device-flow 向 `auth.x.ai` 换 `access_token` / `refresh_token` +2. 组装 `type=xai` 扁平 auth(`cli-chat-proxy.grok.com`) +3. 本地:`cpa_auth_dir` → `xai-.json`(CPA 热加载) +4. 远程:`POST {cpa_remote_url}/v0/management/auth-files?name=...`(需管理密钥) + +### 本地目录 + +```json +{ + "cpa_auto_add": true, + "cpa_auth_dir": "/path/to/CLIProxyAPI/auths" +} +``` + +跨机器 / WSL 可写挂载路径,例如 +`//wsl.localhost/Ubuntu/home/you/CLIProxyAPI/auths`。 + +### 远程 Management API + +```json +{ + "cpa_auto_add": true, + "cpa_auth_dir": "", + "cpa_remote_url": "http://127.0.0.1:8317", + "cpa_management_key": "你的管理密钥明文" +} +``` + +要求 CPA:`remote-management.allow-remote` 按访问方式配置;密钥为配置里的明文(启动后配置文件可能被写成 bcrypt,上传仍用明文)。 + +本地与远程可同时开启。日志前缀:`[CPA]`。 + +### 独立转换 + +已有 SSO 时可脱离注册流程: + +```bash +# 写本地目录 +python sso_to_auth_json.py --sso sso_list.txt --cpa-auth-dir /path/to/auths + +# 上传远程 CPA +python sso_to_auth_json.py --sso sso_list.txt \ + --cpa-remote-url http://127.0.0.1:8317 \ + --cpa-management-key '你的管理密钥' + +# 单个 cookie + 代理 +python sso_to_auth_json.py --sso-cookie 'eyJ...' \ + --cpa-auth-dir ./auths \ + --proxy http://127.0.0.1:7890 +``` + +`sso_list.txt`:一行一个 SSO,或 `邮箱----密码----sso`。 + +## 运行 + +### CLI + +```bash +python grok_register_ttk.py cli +``` + +提示后输入 `start`。 +`Ctrl+C` 一次:当前账号收尾后停止;清理浏览器时不会因二次中断刷 traceback。再按一次强制退出。 + +### GUI + +```bash +python grok_register_ttk.py +``` + +可在界面里改 CPA 开关、auth 目录、远程地址与管理密钥。 + +## 输出文件 + +| 文件 | 内容 | +| --- | --- | +| `accounts_*.txt` | 邮箱、密码、SSO | +| `mail_credentials.txt` | 临时邮箱凭证 | + +均含敏感信息,已在 `.gitignore` 中忽略。`config.json` 也不提交,请用 `config.example.json` 复制。 + +## 稳定性 + +- 每账号结束后重启浏览器 +- 每成功 5 个账号做一次内存清理 +- 邮箱提交后确认页面前进,避免空等验证码 +- 未收到验证码时换邮箱重试 +- 最终页卡住时重试当前账号 + +## 常见问题 + +**CPA 没出现新账号** +检查 `cpa_auto_add`、`cpa_auth_dir` 或 `cpa_remote_url` + `cpa_management_key`;看 `[CPA]` 日志是否换 token / 上传成功;本机/服务器能否访问 `auth.x.ai`。 + +**远程上传失败** +确认 CPA 管理 API 已启用、密钥明文正确;远程访问需 `allow-remote: true`。可用: + +```bash +curl -H "Authorization: Bearer <管理密钥>" \ + http://127.0.0.1:8317/v0/management/auth-files +``` + +**CLI 为什么还开浏览器** +CLI 只是不启动 Tk;注册页、Turnstile、SSO 仍依赖真实浏览器。 + +**NSFW 失败** +常见为 Cloudflare 拦截。账号仍会保存并入库 CPA。 + +**国内服务器调模型超时** +入库成功只说明凭证到了 CPA;调用上游 `cli-chat-proxy.grok.com` 还需服务器出网可达(或配置 CPA `proxy-url`)。 + +## 目录结构 + +```text +. +├── grok_register_ttk.py # 主程序(GUI / CLI + CPA 入库) +├── sso_to_auth_json.py # SSO → CPA 转换(可独立运行) +├── cf_mail_debug.py # Cloudflare 邮箱调试 +├── config.example.json +├── requirements.txt +├── tests/ +└── assets/banner.png +``` + +## License + +[MIT](LICENSE) + +## Acknowledgments + +Thanks to [linux.do](https://linux.do) and [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..ba8cca5 Binary files /dev/null and b/assets/banner.png differ diff --git a/cf_mail_debug.py b/cf_mail_debug.py new file mode 100755 index 0000000..962960c --- /dev/null +++ b/cf_mail_debug.py @@ -0,0 +1,240 @@ +#!/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 + + +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]: + 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) + + +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() + + address = args.address.strip() + credential = args.credential.strip() + if not credential: + 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, + ) + print(f"[NEW] address={address}") + print(f"[NEW] credential(jwt)={credential}") + else: + print(f"[USE] address={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) + total = 0 + for name, mails in boxes: + if mails: + print(f"[BOX] {name} -> {len(mails)}") + total += len(mails) + for m in mails: + mail_id = m.get("id") or m.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}") + if code: + print(f"[FOUND] {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() diff --git a/config.example.json b/config.example.json new file mode 100755 index 0000000..d6fab3f --- /dev/null +++ b/config.example.json @@ -0,0 +1,23 @@ +{ + "duckmail_api_key": "", + "cloudflare_api_base": "https://temp-mail.example.com", + "cloudflare_api_key": "", + "cloudflare_auth_mode": "none", + "cloudflare_custom_auth": "", + "cloudflare_path_domains": "/api/domains", + "cloudflare_path_accounts": "/api/new_address", + "cloudflare_path_token": "/api/token", + "cloudflare_path_messages": "/api/mails", + "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", + "cpa_auto_add": false, + "cpa_auth_dir": "", + "cpa_remote_url": "", + "cpa_management_key": "", + "defaultDomains": "example.com", + "email_provider": "cloudflare", + "yyds_api_key": "", + "yyds_jwt": "" +} diff --git a/grok_register_ttk.py b/grok_register_ttk.py new file mode 100755 index 0000000..47faf4f --- /dev/null +++ b/grok_register_ttk.py @@ -0,0 +1,3165 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Grok 注册机 - TTK GUI 版本 +整合 DrissionPage_example.py, openai_register.py, batch_open_nsfw.py +""" + +import tkinter as tk +from tkinter import ttk, messagebox, scrolledtext +import threading +import datetime +import time +import os +import sys +import signal +import gc +import queue +import secrets +import struct +import random +import re +import string +import json + +os.environ.setdefault("TK_SILENCE_DEPRECATION", "1") + +from DrissionPage import Chromium, ChromiumOptions +from DrissionPage.errors import PageDisconnectedError +from curl_cffi import requests + +# SSO → CLIProxyAPI(CPA) 扁平格式转换(复用 sso_to_auth_json 的 device-flow + 写入器) +import sso_to_auth_json as _s2cpa + + +CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +MEMORY_CLEANUP_INTERVAL = 5 + +UI_BG = "#242424" +UI_PANEL_BG = "#2b2b2b" +UI_FG = "#f2f2f2" +UI_MUTED_FG = "#b8b8b8" +UI_ENTRY_BG = "#333333" +UI_BUTTON_BG = "#3a3a3a" +UI_ACTIVE_BG = "#4a6078" + +DEFAULT_CONFIG = { + "duckmail_api_key": "", + "cloudflare_api_base": "", + "cloudflare_api_key": "", + "cloudflare_auth_mode": "none", + "cloudflare_custom_auth": "", + "cloudflare_path_domains": "/api/domains", + "cloudflare_path_accounts": "/api/new_address", + "cloudflare_path_token": "/api/token", + "cloudflare_path_messages": "/api/mails", + "proxy": "http://127.0.0.1:7890", + "enable_nsfw": True, + "register_count": 1, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + # CLIProxyAPI(CPA) 直出:注册拿到 SSO 后自动 device-flow 换 token 并写成 CPA 扁平格式 + "cpa_auto_add": False, + "cpa_auth_dir": "", + # 远程 CPA:通过 Management API POST /v0/management/auth-files 上传 + "cpa_remote_url": "", + "cpa_management_key": "", +} + +config = DEFAULT_CONFIG.copy() +_cf_domain_index = 0 + + +class RegistrationCancelled(Exception): + pass + + +class AccountRetryNeeded(Exception): + pass + + +def load_config(): + global config + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + loaded = json.load(f) + config = {**DEFAULT_CONFIG, **loaded} + except Exception: + config = DEFAULT_CONFIG.copy() + return config + + +def save_config(): + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + except Exception as e: + print(f"保存配置失败: {e}") + + +def ensure_stable_python_runtime(): + if sys.version_info < (3, 14) or os.environ.get("DPE_REEXEC_DONE") == "1": + return + + local_app_data = os.environ.get("LOCALAPPDATA", "") + candidates = [ + os.path.join(local_app_data, "Programs", "Python", "Python312", "python.exe"), + os.path.join(local_app_data, "Programs", "Python", "Python313", "python.exe"), + ] + + current_python = os.path.normcase(os.path.abspath(sys.executable)) + for candidate in candidates: + if not os.path.isfile(candidate): + continue + if os.path.normcase(os.path.abspath(candidate)) == current_python: + return + + print( + f"[*] 检测到 Python {sys.version.split()[0]},自动切换到更稳定的解释器: {candidate}" + ) + env = os.environ.copy() + env["DPE_REEXEC_DONE"] = "1" + os.execve(candidate, [candidate, os.path.abspath(__file__), *sys.argv[1:]], env) + + +def warn_runtime_compatibility(): + if sys.version_info >= (3, 14): + print( + "[提示] 当前 Python 为 3.14+;若出现 Mail.tm TLS 异常,建议改用 Python 3.12 或 3.13。" + ) + + +ensure_stable_python_runtime() +warn_runtime_compatibility() + +load_config() + +EXTENSION_PATH = os.path.abspath( + os.path.join(os.path.dirname(__file__), "turnstilePatch") +) + + +DUCKMAIL_API_BASE = "https://api.duckmail.sbs" + + +def get_proxies(): + proxy = config.get("proxy", "") + if proxy: + return {"http": proxy, "https": proxy} + return {} + + +def get_duckmail_api_key(): + return config.get("duckmail_api_key", "") + + +def get_cloudflare_api_base(): + return str(config.get("cloudflare_api_base", "") or "").rstrip("/") + + +def get_cloudflare_api_key(): + return config.get("cloudflare_api_key", "") + + +def get_cloudflare_auth_mode(): + return str(config.get("cloudflare_auth_mode", "none") or "none").lower() + + +def get_cloudflare_custom_auth(): + """全局访问密码(cloudflare_temp_email 的 PASSWORDS)。 + + 开启后 Worker 会对除 /open_api、/telegram 外的所有路径校验 x-custom-auth 头, + 与 cloudflare_auth_mode 正交叠加,需要在每个请求上单独注入。 + """ + return str(config.get("cloudflare_custom_auth", "") or "").strip() + + +def cloudflare_apply_custom_auth(headers): + """给请求头注入全局访问密码,若未配置则原样返回。""" + custom_auth = get_cloudflare_custom_auth() + if custom_auth: + headers["x-custom-auth"] = custom_auth + return headers + + +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}" + cloudflare_apply_custom_auth(headers) + 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 = cloudflare_apply_custom_auth({"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_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 _normalize_sso_token(raw_token): + token = str(raw_token or "").strip() + if token.startswith("sso="): + token = token[4:] + return token + + +def add_sso_to_cpa(raw_token, email="", log_callback=None): + """SSO → device-flow 换 token → 写入本地 CPA auth 目录和/或远程 CPA。 + + SSO 本身不是 CPA 认的凭据;必须先用 device flow 换到 access/refresh token, + 再写成 CPA 的 xai-.json(type=xai + cli-chat-proxy base_url + grok-cli headers)。 + + - 本地:写入 cpa_auth_dir,CPA 监听热加载 + - 远程:POST Management API /v0/management/auth-files(cpa_remote_url + cpa_management_key) + """ + if not config.get("cpa_auto_add", False): + return + auth_dir = str(config.get("cpa_auth_dir", "") or "").strip() + remote_url = str(config.get("cpa_remote_url", "") or "").strip() + management_key = str(config.get("cpa_management_key", "") or "").strip() + if not auth_dir and not remote_url: + if log_callback: + log_callback("[Debug] 已开启 CPA 直出但未配置 cpa_auth_dir 或 cpa_remote_url,跳过") + return + if remote_url and not management_key: + if log_callback: + log_callback("[Debug] 已配置 cpa_remote_url 但未配置 cpa_management_key,跳过远程上传") + remote_url = "" + if not auth_dir and not remote_url: + return + sso = _normalize_sso_token(raw_token) + if not sso: + return + proxy = str(config.get("proxy", "") or "").strip() + + def _cpa_log(message): + if log_callback: + log_callback(f"[CPA] {str(message).strip()}") + + try: + _cpa_log("SSO → device-flow 换 token ...") + token = _s2cpa.sso_to_token(sso, proxy=proxy, log=_cpa_log) + if not token: + _cpa_log("device-flow 换 token 失败,跳过") + return + record = _s2cpa.token_to_cpa_record(token, email=email) + if auth_dir: + path = _s2cpa.write_cpa_auth(_s2cpa.Path(auth_dir), record) + _cpa_log(f"已写入本地 {path}") + if remote_url: + name = _s2cpa.upload_cpa_auth_remote(remote_url, management_key, record) + _cpa_log(f"已上传远程 {remote_url.rstrip('/')}/.../{name}") + except Exception as exc: + _cpa_log(f"直出失败: {exc}") + + +def create_browser_options(): + options = ChromiumOptions() + options.auto_port() + options.set_timeouts(base=1) + 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): + try: + return requests.get(url, **_build_request_kwargs(**kwargs)) + except Exception as exc: + err = str(exc) + # 代理不可用时自动回退为直连,避免整个流程直接失败 + if "127.0.0.1 port 7890" in err or "Could not connect to server" in err: + retry_kwargs = dict(kwargs) + retry_kwargs["proxies"] = {} + return requests.get(url, **_build_request_kwargs(**retry_kwargs)) + raise + + +def http_post(url, **kwargs): + try: + return requests.post(url, **_build_request_kwargs(**kwargs)) + except Exception as exc: + err = str(exc) + if "127.0.0.1 port 7890" in err or "Could not connect to server" in err: + retry_kwargs = dict(kwargs) + retry_kwargs["proxies"] = {} + return requests.post(url, **_build_request_kwargs(**retry_kwargs)) + raise + + +def raise_if_cancelled(cancel_callback=None): + if cancel_callback and cancel_callback(): + raise RegistrationCancelled("用户停止注册") + + +def sleep_with_cancel(seconds, cancel_callback=None): + deadline = time.time() + max(seconds, 0) + while True: + raise_if_cancelled(cancel_callback) + remaining = deadline - time.time() + if remaining <= 0: + return + time.sleep(min(0.2, remaining)) + + +def 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 = cloudflare_apply_custom_auth({"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 = cloudflare_apply_custom_auth({"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 == "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 == "cloudflare": + return cloudflare_get_oai_code( + dev_token, + email, + timeout=timeout, + poll_interval=poll_interval, + log_callback=log_callback, + cancel_callback=cancel_callback, + resend_callback=resend_callback, + ) + return duckmail_get_oai_code( + dev_token, + email, + timeout=timeout, + poll_interval=poll_interval, + log_callback=log_callback, + cancel_callback=cancel_callback, + ) + + +def extract_verification_code(text, subject=""): + if subject: + match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE) + if match: + return match.group(1) + match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE) + if match: + return match.group(1) + patterns = [ + r"verification\s+code[:\s]+(\d{4,8})", + r"your\s+code[:\s]+(\d{4,8})", + r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})", + ] + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + return match.group(1) + return None + + +def duckmail_get_oai_code( + dev_token, + email, + timeout=180, + poll_interval=3, + log_callback=None, + cancel_callback=None, +): + deadline = time.time() + timeout + seen_ids = set() + while time.time() < deadline: + raise_if_cancelled(cancel_callback) + try: + messages = get_messages(dev_token) + except Exception as exc: + if log_callback: + log_callback(f"[Debug] 鎷夊彇閭欢鍒楄〃澶辫触: {exc}") + sleep_with_cancel(poll_interval, cancel_callback) + continue + for msg in messages: + msg_id = msg.get("id") or msg.get("msgid") + if not msg_id or msg_id in seen_ids: + continue + seen_ids.add(msg_id) + recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])] + if email.lower() not in recipients: + continue + try: + detail = get_message_detail(dev_token, msg_id) + except Exception as exc: + if log_callback: + log_callback(f"[Debug] 鑾峰彇閭欢璇︽儏澶辫触: {exc}") + continue + parts = [] + text_body = detail.get("text") or "" + if text_body: + parts.append(text_body) + html_list = detail.get("html") or [] + for h in html_list: + parts.append(re.sub(r"<[^>]+>", " ", h)) + combined = "\n".join(parts) + subject = detail.get("subject", "") + if log_callback: + log_callback(f"[Debug] 鏀跺埌閭欢: {subject}") + code = extract_verification_code(combined, subject) + if code: + if log_callback: + log_callback(f"[*] 浠庨偖浠朵腑鎻愬彇鍒伴獙璇佺爜: {code}") + return code + sleep_with_cancel(poll_interval, cancel_callback) + raise Exception(f"在 {timeout}s 内未收到验证码邮件") + + +def cloudflare_get_oai_code( + dev_token, + email, + timeout=180, + poll_interval=3, + log_callback=None, + cancel_callback=None, + resend_callback=None, +): + api_base = get_cloudflare_api_base() + if not api_base: + raise Exception("Cloudflare API Base 未配置") + deadline = time.time() + timeout + # 同一封邮件正文可能延迟可读,允许多次重试解析,避免偶发漏码 + seen_attempts = {} + next_resend_at = time.time() + 35 + while time.time() < deadline: + raise_if_cancelled(cancel_callback) + if resend_callback and time.time() >= next_resend_at: + try: + resend_callback() + if log_callback: + log_callback("[*] 已触发重新发送验证码") + except Exception as exc: + if log_callback: + log_callback(f"[Debug] 触发重发验证码失败: {exc}") + next_resend_at = time.time() + 35 + try: + messages = cloudflare_get_messages(api_base, dev_token) + except Exception as exc: + if log_callback: + log_callback(f"[Debug] Cloudflare 拉取邮件列表失败: {exc}") + sleep_with_cancel(poll_interval, cancel_callback) + continue + if log_callback: + log_callback(f"[Debug] Cloudflare 本轮邮件数量: {len(messages)}") + + for msg in messages: + msg_id = msg.get("id") or msg.get("msgid") + if not msg_id: + continue + attempt = int(seen_attempts.get(msg_id, 0)) + if attempt >= 5: + continue + seen_attempts[msg_id] = attempt + 1 + recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])] + msg_addr = str(msg.get("address", "")).lower() + # 优先匹配目标邮箱;若结构不一致也允许继续解析,避免接口字段漂移导致漏码 + address_matched = True + if recipients: + address_matched = email.lower() in recipients + elif msg_addr: + address_matched = msg_addr == email.lower() + if not address_matched and log_callback: + log_callback(f"[Debug] 跳过疑似非目标邮件 id={msg_id} address={msg_addr} to={recipients}") + continue + parts = [] + # 先直接从列表项取内容,避免 detail 接口差异导致漏码 + for field in ("text", "raw", "content", "intro", "body", "snippet"): + value = msg.get(field) + if isinstance(value, str) and value.strip(): + parts.append(value) + html_list = msg.get("html") or [] + if isinstance(html_list, str): + html_list = [html_list] + for h in html_list: + parts.append(re.sub(r"<[^>]+>", " ", h)) + subject = str(msg.get("subject", "") or "") + combined = "\n".join(parts) + # 再尝试 detail 接口补全内容 + try: + detail = cloudflare_get_message_detail(api_base, dev_token, msg_id) + for field in ("text", "raw", "content", "intro", "body", "snippet"): + value = detail.get(field) + if isinstance(value, str) and value.strip(): + combined += "\n" + value + html_list2 = detail.get("html") or [] + if isinstance(html_list2, str): + html_list2 = [html_list2] + for h in html_list2: + combined += "\n" + re.sub(r"<[^>]+>", " ", h) + if not subject: + subject = str(detail.get("subject", "") or "") + except Exception as exc: + if log_callback: + log_callback(f"[Debug] Cloudflare detail接口失败,改用列表内容解析: {exc}") + if log_callback: + log_callback(f"[Debug] Cloudflare 收到邮件: {subject}") + code = extract_verification_code(combined, subject) + if code: + if log_callback: + log_callback(f"[*] Cloudflare 从邮件中提取到验证码: {code}") + return code + elif log_callback: + log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}") + sleep_with_cancel(poll_interval, cancel_callback) + raise Exception(f"Cloudflare 在 {timeout}s 内未收到验证码邮件") + + +def generate_random_birthdate(): + import datetime as dt + + today = dt.date.today() + age = random.randint(20, 40) + birth_year = today.year - age + birth_month = random.randint(1, 12) + birth_day = random.randint(1, 28) + return f"{birth_year}-{birth_month:02d}-{birth_day:02d}T16:00:00.000Z" + + +def response_preview(res, limit=200): + """安全预览 HTTP 响应体;gRPC/二进制内容不直接当文本打印。""" + try: + headers = {str(k).lower(): str(v).lower() for k, v in dict(getattr(res, "headers", {}) or {}).items()} + content_type = headers.get("content-type", "") + raw = getattr(res, "content", None) + if raw is None: + try: + raw = (res.text or "").encode("utf-8", errors="replace") + except Exception: + raw = b"" + if not isinstance(raw, (bytes, bytearray)): + raw = str(raw).encode("utf-8", errors="replace") + raw = bytes(raw) + + # gRPC / protobuf 常见 content-type 或正文以不可打印字节为主 + is_binaryish = ( + "grpc" in content_type + or "protobuf" in content_type + or "octet-stream" in content_type + or (raw[:1] in (b"\x00", b"\x01") and b"grpc-status" in raw) + ) + if is_binaryish or (raw and sum(1 for b in raw[:64] if b < 9 or (13 < b < 32)) > 8): + # 尽量抽出可读的 trailer 片段(如 grpc-status:0) + readable = re.findall(rb"[ -~]{3,}", raw) + text = " ".join(part.decode("ascii", errors="ignore") for part in readable) + text = re.sub(r"\s+", " ", text).strip() + if not text: + text = f"" + return text[:limit] + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + text = raw.decode("utf-8", errors="replace") + text = re.sub(r"\s+", " ", text).strip() + return text[:limit] + except Exception: + return "" + + +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="", user_agent="", log_callback=None): + proxies = get_proxies() + # cf_clearance 与签发它的浏览器 UA 严格绑定,优先用注册浏览器的真实 UA + ua = user_agent or 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": ua, + "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 + + +def setup_light_theme(root): + try: + root.option_add("*Background", UI_BG) + root.option_add("*Foreground", UI_FG) + root.option_add("*selectBackground", UI_ACTIVE_BG) + root.option_add("*selectForeground", UI_FG) + root.option_add("*insertBackground", UI_FG) + root.option_add("*Entry.Background", UI_ENTRY_BG) + root.option_add("*Text.Background", UI_ENTRY_BG) + root.option_add("*Menu.Background", UI_ENTRY_BG) + root.option_add("*Menu.Foreground", UI_FG) + style = ttk.Style(root) + available = set(style.theme_names()) + if "clam" in available: + style.theme_use("clam") + elif "default" in available: + style.theme_use("default") + root.configure(bg=UI_BG) + style.configure(".", background=UI_BG, foreground=UI_FG, fieldbackground=UI_ENTRY_BG) + style.configure("TFrame", background=UI_BG) + style.configure("TLabelframe", background=UI_BG, foreground=UI_FG) + style.configure("TLabelframe.Label", background=UI_BG, foreground=UI_FG) + style.configure("TLabel", background=UI_BG, foreground=UI_FG) + style.configure("TCheckbutton", background=UI_BG, foreground=UI_FG) + style.configure("TButton", background=UI_BUTTON_BG, foreground=UI_FG) + style.configure("TEntry", fieldbackground=UI_ENTRY_BG, foreground=UI_FG) + style.configure("TCombobox", fieldbackground=UI_ENTRY_BG, foreground=UI_FG) + style.configure("TSpinbox", fieldbackground=UI_ENTRY_BG, foreground=UI_FG) + except Exception: + pass + + +def tk_label(parent, text="", **kwargs): + return tk.Label(parent, text=text, bg=kwargs.pop("bg", UI_BG), fg=kwargs.pop("fg", UI_FG), **kwargs) + + +def tk_entry(parent, textvariable=None, width=30, **kwargs): + return tk.Entry( + parent, + textvariable=textvariable, + width=width, + bg=UI_ENTRY_BG, + fg=UI_FG, + insertbackground=UI_FG, + disabledbackground="#2f2f2f", + disabledforeground=UI_MUTED_FG, + highlightthickness=1, + highlightbackground="#555555", + relief=tk.SOLID, + **kwargs, + ) + + +def tk_button(parent, text="", command=None, state=tk.NORMAL, **kwargs): + return tk.Button( + parent, + text=text, + command=command, + state=state, + bg=UI_BUTTON_BG, + fg=UI_FG, + activebackground=UI_ACTIVE_BG, + activeforeground=UI_FG, + disabledforeground="#777777", + relief=tk.RAISED, + padx=10, + pady=3, + **kwargs, + ) + + +def tk_checkbutton(parent, text="", variable=None, **kwargs): + return tk.Checkbutton( + parent, + text=text, + variable=variable, + bg=UI_BG, + fg=UI_FG, + activebackground=UI_BG, + activeforeground=UI_FG, + selectcolor="#3d7be0", + **kwargs, + ) + + +def tk_option_menu(parent, variable, values, width=12): + menu = tk.OptionMenu(parent, variable, *values) + menu.configure( + width=width, + bg=UI_ENTRY_BG, + fg=UI_FG, + activebackground=UI_ACTIVE_BG, + activeforeground=UI_FG, + highlightthickness=1, + highlightbackground="#555555", + relief=tk.SOLID, + ) + menu["menu"].configure(bg=UI_ENTRY_BG, fg=UI_FG, activebackground=UI_ACTIVE_BG, activeforeground=UI_FG) + return menu + + +def start_browser(log_callback=None): + global browser, page + last_exc = None + for attempt in range(1, 5): + try: + browser = Chromium(create_browser_options()) + 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 attempt > 1: + log_callback(f"[*] 浏览器第 {attempt} 次启动成功") + return browser, page + except Exception as exc: + last_exc = exc + if log_callback: + log_callback(f"[Debug] 浏览器启动失败(第{attempt}/4次): {exc}") + try: + if browser is not None: + browser.quit(del_data=True) + except Exception: + pass + browser = None + page = None + time.sleep(min(1.5 * attempt, 4)) + raise Exception(f"浏览器启动失败,已重试4次: {last_exc}") + + +def stop_browser(): + global browser, page + current = browser + browser = None + page = None + if current is None: + return + try: + current.quit(del_data=True) + except BaseException: + # KeyboardInterrupt 继承 BaseException,清理阶段必须吞掉,避免 Ctrl+C 刷 traceback + pass + + +def restart_browser(log_callback=None): + stop_browser() + return start_browser(log_callback=log_callback) + + +def cleanup_runtime_memory(log_callback=None, reason="定期清理"): + try: + if log_callback: + log_callback(f"[*] {reason}: 关闭浏览器并清理内存") + stop_browser() + collected = gc.collect() + if log_callback: + log_callback(f"[*] Python GC 已回收对象数: {collected}") + except BaseException: + # 退出清理中再收到 Ctrl+C 时静默结束,不向外抛 + try: + stop_browser() + except BaseException: + pass + + +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 extract_cf_clearance_and_ua(log_callback=None): + """从注册浏览器提取 grok.com 的 cf_clearance 及其绑定的真实 UA。 + + 注册流程能拿到 sso 说明浏览器已通过 grok.com 的 Cloudflare 盾, + 此刻 cf_clearance 就在浏览器 cookie 里,配合真实 UA 可用于后续 NSFW 请求。 + + 返回: + - (cf_clearance str, user_agent str):任一取不到则为空字符串 + """ + cf_clearance = "" + user_agent = "" + try: + active = refresh_active_page() + if active is None: + return "", "" + cookies = active.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 == "cf_clearance" and value: + cf_clearance = value + break + try: + ua = active.run_js("return navigator.userAgent;") + if ua: + user_agent = str(ua).strip() + except Exception: + pass + except Exception as exc: + if log_callback: + log_callback(f"[Debug] 提取 cf_clearance 失败: {exc}") + return cf_clearance, user_agent + + +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() + if log_callback: + log_callback("[*] 浏览器已启动") + try: + page = browser.get_tab(0) + page.get(SIGNUP_URL) + except Exception as e: + if log_callback: + log_callback(f"[Debug] 打开URL异常: {e}") + try: + page = browser.new_tab(SIGNUP_URL) + except Exception as e2: + if log_callback: + log_callback(f"[Debug] 创建新标签页异常: {e2}") + restart_browser() + page = browser.new_tab(SIGNUP_URL) + page.wait.doc_loaded() + 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 _email_page_advanced_once(email): + """检测邮箱提交后页面是否真正前进(离开邮箱输入阶段)。 + + 点击注册按钮只代表触发了点击,不代表表单真的提交成功。 + 若 Cloudflare 挑战未过或页面卡住,按钮点击无实际效果, + 邮箱输入框会一直停留,导致后续空等验证码。 + + 判定“已前进”的依据: + - 出现验证码输入框(OTP / code 输入),或 + - 原本可见可用的邮箱输入框已消失/不可用 + + 返回: + - True:页面已前进,提交生效 + - False:仍停留在邮箱输入页 + """ + try: + return bool( + page.run_js( + """ +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.getAttribute('aria-label'), + node.getAttribute('placeholder'), + node.getAttribute('name'), + node.getAttribute('id'), + node.getAttribute('autocomplete'), + node.getAttribute('data-testid'), + ].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim().toLowerCase(); +} +// 1. 出现验证码输入框 => 已前进 +const codeInput = Array.from(document.querySelectorAll('input')).find((node) => { + if (!isVisible(node)) return false; + const type = (node.getAttribute('type') || '').toLowerCase(); + if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file'].includes(type)) return false; + const meta = textOf(node); + const inMode = (node.getAttribute('inputmode') || '').toLowerCase(); + return ( + meta.includes('code') || meta.includes('otp') || meta.includes('verif') || + meta.includes('验证') || meta.includes('one-time') || inMode === 'numeric' || + node.getAttribute('autocomplete') === 'one-time-code' + ); +}); +if (codeInput) return true; +// 2. 邮箱输入框已消失/不可用 => 已前进 +const emailInput = 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]')) + .find((node) => isVisible(node) && !node.disabled && !node.readOnly); +if (!emailInput) return true; +return false; + """ + ) + ) + except Exception: + return False + + +def _wait_email_page_advanced(email, wait=4.0, cancel_callback=None): + """点击提交后,在有限窗口内轮询确认页面确实前进。 + + 给页面/网络一点反应时间:若窗口内检测到已前进则返回 True, + 否则返回 False,由调用方继续重试点击或最终超时换邮箱。 + """ + deadline = time.time() + wait + while time.time() < deadline: + raise_if_cancelled(cancel_callback) + if _email_page_advanced_once(email): + return True + sleep_with_cancel(0.4, cancel_callback) + 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( + r""" +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: + # 点击按钮 != 表单真正提交成功:CF 挑战未过或页面卡住时点击无效果, + # 邮件不会发出。必须确认页面已离开邮箱输入阶段(邮箱框消失或出现验证码框), + # 否则继续循环重试点击,最终超时抛异常触发换邮箱重试。 + if _wait_email_page_advanced(email, cancel_callback=cancel_callback): + if log_callback: + detail = f" ({clicked})" if isinstance(clicked, str) else "" + log_callback(f"[*] 已填写邮箱并提交: {email}{detail}") + return email, dev_token + if log_callback and time.time() - last_diag_time >= 5: + last_diag_time = time.time() + log_callback(f"[Debug] 已点击注册但页面未前进,重试提交: {email}") + 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 + + 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: + pass + + sleep_with_cancel(1, cancel_callback) + + raise Exception( + f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}" + ) + + +class GrokRegisterGUI: + def __init__(self, root): + self.root = root + self.root.title("Grok 注册机") + self.root.geometry("1120x900") + self.root.minsize(960, 700) + self.is_running = False + self.batch_count = 0 + self.success_count = 0 + self.fail_count = 0 + self.results = [] + self.stop_requested = False + self.ui_queue = queue.Queue() + self.accounts_output_file = "" + self.setup_ui() + + def setup_ui(self): + load_config() + main_frame = tk.Frame(self.root, bg=UI_BG, padx=10, pady=10) + main_frame.pack(fill=tk.BOTH, expand=True) + main_frame.grid_columnconfigure(0, weight=1) + main_frame.grid_rowconfigure(3, weight=1) + + config_frame = tk.LabelFrame( + main_frame, + text="配置", + bg=UI_PANEL_BG, + fg=UI_FG, + padx=10, + pady=10, + relief=tk.GROOVE, + borderwidth=1, + ) + config_frame.grid(row=0, column=0, sticky=tk.EW, pady=(0, 8)) + config_frame.grid_columnconfigure(1, weight=1, minsize=260) + config_frame.grid_columnconfigure(3, weight=1, minsize=260) + + def add_label(row, column, text): + tk_label(config_frame, text=text, bg=UI_PANEL_BG).grid( + row=row, + column=column, + sticky=tk.W, + padx=(0, 6), + pady=3, + ) + + def add_field(widget, row, column, columnspan=1, sticky=tk.EW): + widget.grid( + row=row, + column=column, + columnspan=columnspan, + sticky=sticky, + padx=(0, 14), + pady=3, + ) + + add_label(0, 0, "邮箱服务商:") + self.email_provider_var = tk.StringVar(value=config.get("email_provider", "duckmail")) + self.email_provider_combo = tk_option_menu(config_frame, self.email_provider_var, ["duckmail", "yyds", "cloudflare"], width=12) + add_field(self.email_provider_combo, 0, 1, sticky=tk.W) + + add_label(0, 2, "注册数量:") + self.count_var = tk.StringVar(value=str(config.get("register_count", 1))) + self.count_spinbox = tk.Spinbox( + config_frame, + from_=1, + to=2500, + width=8, + textvariable=self.count_var, + bg=UI_ENTRY_BG, + fg=UI_FG, + insertbackground=UI_FG, + buttonbackground=UI_BUTTON_BG, + disabledbackground="#2f2f2f", + disabledforeground=UI_MUTED_FG, + relief=tk.SOLID, + ) + add_field(self.count_spinbox, 0, 3, sticky=tk.W) + + add_label(1, 0, "注册选项:") + self.nsfw_var = tk.BooleanVar(value=config.get("enable_nsfw", True)) + self.nsfw_check = tk_checkbutton(config_frame, text="注册后开启 NSFW", variable=self.nsfw_var) + add_field(self.nsfw_check, 1, 1, sticky=tk.W) + + add_label(1, 2, "代理(可选):") + self.proxy_var = tk.StringVar(value=config.get("proxy", "")) + self.proxy_entry = tk_entry(config_frame, textvariable=self.proxy_var, width=34) + add_field(self.proxy_entry, 1, 3) + + add_label(2, 0, "DuckMail API Key:") + self.api_key_var = tk.StringVar(value=config.get("duckmail_api_key", "")) + self.api_key_entry = tk_entry(config_frame, textvariable=self.api_key_var, width=34) + add_field(self.api_key_entry, 2, 1) + + add_label(2, 2, "Cloudflare 鉴权模式:") + self.cloudflare_auth_mode_var = tk.StringVar(value=config.get("cloudflare_auth_mode", "none")) + self.cloudflare_auth_mode_combo = tk_option_menu( + config_frame, self.cloudflare_auth_mode_var, ["query-key", "bearer", "x-api-key", "x-admin-auth", "none"], width=12 + ) + add_field(self.cloudflare_auth_mode_combo, 2, 3, sticky=tk.W) + + add_label(3, 0, "Cloudflare API Base:") + self.cloudflare_api_base_var = tk.StringVar(value=config.get("cloudflare_api_base", "")) + self.cloudflare_api_base_entry = tk_entry(config_frame, textvariable=self.cloudflare_api_base_var, width=72) + add_field(self.cloudflare_api_base_entry, 3, 1, columnspan=3) + + add_label(4, 0, "Cloudflare API Key:") + self.cloudflare_api_key_var = tk.StringVar(value=config.get("cloudflare_api_key", "")) + self.cloudflare_api_key_entry = tk_entry(config_frame, textvariable=self.cloudflare_api_key_var, width=34) + add_field(self.cloudflare_api_key_entry, 4, 1) + + add_label(4, 2, "CF 路径:") + self.cloudflare_paths_var = tk.StringVar( + value=",".join( + [ + config.get("cloudflare_path_domains", "/api/domains"), + config.get("cloudflare_path_accounts", "/api/new_address"), + config.get("cloudflare_path_token", "/api/token"), + config.get("cloudflare_path_messages", "/api/mails"), + ] + ) + ) + self.cloudflare_paths_entry = tk_entry(config_frame, textvariable=self.cloudflare_paths_var, width=34) + add_field(self.cloudflare_paths_entry, 4, 3) + + add_label(5, 0, "CPA 直出(SSO→auth):") + self.cpa_auto_add_var = tk.BooleanVar(value=bool(config.get("cpa_auto_add", False))) + self.cpa_auto_add_check = tk_checkbutton(config_frame, variable=self.cpa_auto_add_var) + add_field(self.cpa_auto_add_check, 5, 1, sticky=tk.W) + + add_label(6, 0, "CPA auth 目录:") + self.cpa_auth_dir_var = tk.StringVar(value=str(config.get("cpa_auth_dir", ""))) + self.cpa_auth_dir_entry = tk_entry(config_frame, textvariable=self.cpa_auth_dir_var, width=72) + add_field(self.cpa_auth_dir_entry, 6, 1, columnspan=3) + + add_label(7, 0, "CPA 远程地址:") + self.cpa_remote_url_var = tk.StringVar(value=str(config.get("cpa_remote_url", ""))) + self.cpa_remote_url_entry = tk_entry(config_frame, textvariable=self.cpa_remote_url_var, width=40) + add_field(self.cpa_remote_url_entry, 7, 1) + + add_label(7, 2, "CPA 管理密钥:") + self.cpa_management_key_var = tk.StringVar(value=str(config.get("cpa_management_key", ""))) + self.cpa_management_key_entry = tk_entry(config_frame, textvariable=self.cpa_management_key_var, width=28) + add_field(self.cpa_management_key_entry, 7, 3) + + btn_frame = tk.Frame(main_frame, bg=UI_BG) + btn_frame.grid(row=1, column=0, sticky=tk.EW, pady=(0, 6)) + self.start_btn = tk_button(btn_frame, text="开始注册", command=self.start_registration) + self.start_btn.pack(side=tk.LEFT, padx=5) + self.stop_btn = tk_button(btn_frame, text="停止", command=self.stop_registration, state=tk.DISABLED) + self.stop_btn.pack(side=tk.LEFT, padx=5) + self.clear_btn = tk_button(btn_frame, text="清空日志", command=self.clear_log) + self.clear_btn.pack(side=tk.LEFT, padx=5) + + status_frame = tk.Frame(main_frame, bg=UI_BG) + status_frame.grid(row=2, column=0, sticky=tk.EW, pady=(0, 6)) + self.status_var = tk.StringVar(value="就绪") + tk_label(status_frame, text="状态: ").pack(side=tk.LEFT) + self.status_label = tk.Label(status_frame, textvariable=self.status_var, bg=UI_BG, fg="green") + self.status_label.pack(side=tk.LEFT) + self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0") + tk.Label(status_frame, textvariable=self.stats_var, bg=UI_BG, fg=UI_FG).pack(side=tk.RIGHT) + log_frame = tk.LabelFrame( + main_frame, + text="日志", + bg=UI_PANEL_BG, + fg=UI_FG, + padx=5, + pady=5, + relief=tk.GROOVE, + borderwidth=1, + ) + log_frame.grid(row=3, column=0, sticky=tk.NSEW) + log_frame.grid_columnconfigure(0, weight=1) + log_frame.grid_rowconfigure(0, weight=1) + self.log_text = scrolledtext.ScrolledText( + log_frame, + height=18, + width=60, + bg="#111111", + fg="#f5f5f5", + insertbackground="#f5f5f5", + selectbackground="#345a8a", + selectforeground="#ffffff", + relief=tk.SOLID, + borderwidth=1, + highlightthickness=1, + highlightbackground="#555555", + ) + self.log_text.grid(row=0, column=0, sticky=tk.NSEW) + self.log("[*] GUI 已就绪,配置已加载") + self.log(f"[*] 当前邮箱服务商: {self.email_provider_var.get()} | 注册数量: {self.count_var.get()}") + + def log(self, message): + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + line = f"[{timestamp}] {message}" + print(line, flush=True) + self.log_text.insert(tk.END, f"{line}\n") + self.log_text.see(tk.END) + + def clear_log(self): + self.log_text.delete(1.0, tk.END) + + def update_stats(self): + self.stats_var.set(f"成功: {self.success_count} | 失败: {self.fail_count}") + + def _set_running_ui(self, running): + self.is_running = running + self.start_btn.config(state=tk.DISABLED if running else tk.NORMAL) + self.stop_btn.config(state=tk.NORMAL if running else tk.DISABLED) + self.status_var.set("运行中..." if running else "就绪") + self.status_label.config(foreground="blue" if running else "green") + + def should_stop(self): + return self.stop_requested or not self.is_running + + def start_registration(self): + if self.is_running: + self.log("[!] 当前已有任务在运行") + return + + config["email_provider"] = self.email_provider_var.get().strip() or "duckmail" + config["enable_nsfw"] = bool(self.nsfw_var.get()) + config["proxy"] = self.proxy_var.get().strip() + config["duckmail_api_key"] = self.api_key_var.get().strip() + config["cloudflare_api_base"] = self.cloudflare_api_base_var.get().strip() + config["cloudflare_api_key"] = self.cloudflare_api_key_var.get().strip() + config["cloudflare_auth_mode"] = self.cloudflare_auth_mode_var.get().strip() or "none" + config["cpa_auto_add"] = bool(self.cpa_auto_add_var.get()) + config["cpa_auth_dir"] = self.cpa_auth_dir_var.get().strip() + config["cpa_remote_url"] = self.cpa_remote_url_var.get().strip() + config["cpa_management_key"] = self.cpa_management_key_var.get().strip() + raw_paths = [x.strip() for x in self.cloudflare_paths_var.get().split(",") if x.strip()] + if len(raw_paths) >= 4: + config["cloudflare_path_domains"] = raw_paths[0] if raw_paths[0].startswith("/") else ("/" + raw_paths[0]) + config["cloudflare_path_accounts"] = raw_paths[1] if raw_paths[1].startswith("/") else ("/" + raw_paths[1]) + config["cloudflare_path_token"] = raw_paths[2] if raw_paths[2].startswith("/") else ("/" + raw_paths[2]) + config["cloudflare_path_messages"] = raw_paths[3] if raw_paths[3].startswith("/") else ("/" + raw_paths[3]) + save_config() + if config["email_provider"] == "cloudflare" and not config["cloudflare_api_base"]: + self.log("[!] Cloudflare 模式需要先填写 Cloudflare API Base") + return + try: + count = int(self.count_var.get()) + except Exception: + self.log("[!] 注册数量无效") + return + config["register_count"] = count + save_config() + self.stop_requested = False + self.success_count = 0 + self.fail_count = 0 + self.results = [] + now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + self.accounts_output_file = os.path.join( + os.path.dirname(__file__), f"accounts_{now}.txt" + ) + self.update_stats() + self._set_running_ui(True) + self.log(f"[*] 配置已保存,开始执行。目标数量: {count}") + self.log(f"[*] 成功账号将实时保存到: {self.accounts_output_file}") + threading.Thread( + target=self.run_registration, + args=(count,), + daemon=True, + ).start() + + def stop_registration(self): + self.stop_requested = True + self.log("[!] 用户停止注册") + + def run_registration(self, count): + try: + start_browser(log_callback=self.log) + self.log("[*] 浏览器已启动") + i = 0 + retry_count_for_slot = 0 + max_slot_retry = 3 + while i < count: + if self.should_stop(): + break + self.log(f"--- 开始第 {i + 1}/{count} 个账号 ---") + try: + email = "" + dev_token = "" + code = "" + mail_ok = False + max_mail_retry = 3 + for mail_try in range(1, max_mail_retry + 1): + self.log(f"[*] 1. 打开注册页 (尝试 {mail_try}/{max_mail_retry})") + open_signup_page( + log_callback=self.log, cancel_callback=self.should_stop + ) + self.log("[*] 2. 创建邮箱并提交") + email, dev_token = fill_email_and_submit( + log_callback=self.log, cancel_callback=self.should_stop + ) + self.log(f"[*] 邮箱: {email}") + self.log(f"[Debug] 邮箱credential(jwt): {dev_token}") + try: + with open( + os.path.join(os.path.dirname(__file__), "mail_credentials.txt"), + "a", + encoding="utf-8", + ) as f: + f.write(f"{email}\t{dev_token}\n") + except Exception: + pass + self.log("[*] 3. 拉取验证码") + try: + code = fill_code_and_submit( + email, + dev_token, + log_callback=self.log, + cancel_callback=self.should_stop, + ) + mail_ok = True + break + except Exception as mail_exc: + msg = str(mail_exc) + if ("未收到验证码" in msg or "验证码" in msg) and mail_try < max_mail_retry: + self.log(f"[!] 本邮箱未取到验证码,自动更换新邮箱重试: {msg}") + restart_browser(log_callback=self.log) + sleep_with_cancel(1, self.should_stop) + continue + raise + + if not mail_ok: + raise Exception("验证码阶段失败,已达到最大重试次数") + self.log(f"[*] 验证码: {code}") + self.log("[*] 4. 填写资料") + profile = fill_profile_and_submit( + log_callback=self.log, cancel_callback=self.should_stop + ) + self.log(f"[*] 资料已填: {profile.get('given_name')} {profile.get('family_name')}") + self.log("[*] 5. 等待 sso cookie") + sso = wait_for_sso_cookie( + log_callback=self.log, cancel_callback=self.should_stop + ) + if config.get("enable_nsfw", True): + self.log("[*] 6. 开启 NSFW") + cf_clearance, browser_ua = extract_cf_clearance_and_ua(self.log) + nsfw_ok, nsfw_msg = enable_nsfw_for_token( + sso, cf_clearance=cf_clearance, user_agent=browser_ua, log_callback=self.log + ) + if nsfw_ok: + self.log(f"[+] NSFW 开启成功: {nsfw_msg}") + else: + self.log(f"[!] NSFW 未开启,继续保存账号: {nsfw_msg}") + self.results.append({"email": email, "sso": sso, "profile": profile}) + try: + line = f"{email}----{profile.get('password','')}----{sso}\n" + with open(self.accounts_output_file, "a", encoding="utf-8") as f: + f.write(line) + except Exception as file_exc: + self.log(f"[Debug] 保存账号文件失败: {file_exc}") + add_sso_to_cpa(sso, email=email, log_callback=self.log) + self.success_count += 1 + retry_count_for_slot = 0 + i += 1 + self.log(f"[+] 注册成功: {email}") + if ( + self.success_count > 0 + and self.success_count % MEMORY_CLEANUP_INTERVAL == 0 + and i < count + ): + cleanup_runtime_memory( + log_callback=self.log, + reason=f"已成功 {self.success_count} 个账号,执行定期清理", + ) + except RegistrationCancelled: + self.log("[!] 注册被用户停止") + break + except AccountRetryNeeded as exc: + retry_count_for_slot += 1 + if retry_count_for_slot <= max_slot_retry: + self.log( + f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{max_slot_retry} 次: {exc}" + ) + else: + self.fail_count += 1 + self.log( + f"[-] 当前账号已达到最大重试次数,跳过: {exc}" + ) + retry_count_for_slot = 0 + i += 1 + except Exception as exc: + self.fail_count += 1 + retry_count_for_slot = 0 + i += 1 + self.log(f"[-] 注册失败: {exc}") + finally: + self.update_stats() + if self.should_stop(): + break + if browser is None: + start_browser(log_callback=self.log) + else: + restart_browser(log_callback=self.log) + # 停止后不再调用 cancel_callback,避免 finally 里二次抛出 RegistrationCancelled + time.sleep(1) + except RegistrationCancelled: + self.log("[!] 注册被用户停止") + except Exception as exc: + self.log(f"[!] 任务异常: {exc}") + finally: + stop_browser() + self._set_running_ui(False) + self.log("[*] 任务结束") + + +class CliStopController: + def __init__(self): + self.stop_requested = False + + def should_stop(self): + return self.stop_requested + + def stop(self): + self.stop_requested = True + + +def cli_log(message): + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + print(f"[{timestamp}] {message}", flush=True) + + +def run_registration_cli(count): + controller = CliStopController() + + # 一次 Ctrl+C 可靠置停:SIGINT 处理器直接设停止标志,不依赖异常在 + # curl_cffi C 回调里向上传播(那里 KeyboardInterrupt 会被吞掉,导致 + # 第一次 Ctrl+C 无效、循环继续跑下一个账号)。连按两次 Ctrl+C 时第二次 + # 恢复默认行为强制中断。 + _prev_sigint = signal.getsignal(signal.SIGINT) + + def _on_sigint(signum, frame): + if controller.should_stop(): + # 第二次:恢复默认并重新抛出,强制中断 + signal.signal(signal.SIGINT, _prev_sigint) + raise KeyboardInterrupt + controller.stop() + cli_log("[!] 收到 Ctrl+C,正在停止(再按一次强制中断)") + + signal.signal(signal.SIGINT, _on_sigint) + success_count = 0 + fail_count = 0 + retry_count_for_slot = 0 + max_slot_retry = 3 + accounts_output_file = os.path.join( + os.path.dirname(__file__), + f"accounts_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt", + ) + cli_log(f"[*] 终端模式启动,目标数量: {count}") + cli_log(f"[*] 成功账号将实时保存到: {accounts_output_file}") + try: + start_browser(log_callback=cli_log) + cli_log("[*] 浏览器已启动") + i = 0 + while i < count: + if controller.should_stop(): + break + cli_log(f"--- 开始第 {i + 1}/{count} 个账号 ---") + try: + email = "" + dev_token = "" + code = "" + mail_ok = False + max_mail_retry = 3 + for mail_try in range(1, max_mail_retry + 1): + cli_log(f"[*] 1. 打开注册页 (尝试 {mail_try}/{max_mail_retry})") + open_signup_page( + log_callback=cli_log, cancel_callback=controller.should_stop + ) + cli_log("[*] 2. 创建邮箱并提交") + email, dev_token = fill_email_and_submit( + log_callback=cli_log, cancel_callback=controller.should_stop + ) + cli_log(f"[*] 邮箱: {email}") + cli_log(f"[Debug] 邮箱credential(jwt): {dev_token}") + try: + with open( + os.path.join(os.path.dirname(__file__), "mail_credentials.txt"), + "a", + encoding="utf-8", + ) as f: + f.write(f"{email}\t{dev_token}\n") + except Exception: + pass + cli_log("[*] 3. 拉取验证码") + try: + code = fill_code_and_submit( + email, + dev_token, + log_callback=cli_log, + cancel_callback=controller.should_stop, + ) + mail_ok = True + break + except Exception as mail_exc: + msg = str(mail_exc) + if ("未收到验证码" in msg or "验证码" in msg) and mail_try < max_mail_retry: + cli_log(f"[!] 本邮箱未取到验证码,自动更换新邮箱重试: {msg}") + restart_browser(log_callback=cli_log) + sleep_with_cancel(1, controller.should_stop) + continue + raise + + if not mail_ok: + raise Exception("验证码阶段失败,已达到最大重试次数") + cli_log(f"[*] 验证码: {code}") + cli_log("[*] 4. 填写资料") + profile = fill_profile_and_submit( + log_callback=cli_log, cancel_callback=controller.should_stop + ) + cli_log(f"[*] 资料已填: {profile.get('given_name')} {profile.get('family_name')}") + cli_log("[*] 5. 等待 sso cookie") + sso = wait_for_sso_cookie( + log_callback=cli_log, cancel_callback=controller.should_stop + ) + if config.get("enable_nsfw", True): + cli_log("[*] 6. 开启 NSFW") + cf_clearance, browser_ua = extract_cf_clearance_and_ua(log_callback=cli_log) + nsfw_ok, nsfw_msg = enable_nsfw_for_token( + sso, cf_clearance=cf_clearance, user_agent=browser_ua, log_callback=cli_log + ) + if nsfw_ok: + cli_log(f"[+] NSFW 开启成功: {nsfw_msg}") + else: + cli_log(f"[!] NSFW 未开启,继续保存账号: {nsfw_msg}") + try: + line = f"{email}----{profile.get('password','')}----{sso}\n" + with open(accounts_output_file, "a", encoding="utf-8") as f: + f.write(line) + except Exception as file_exc: + cli_log(f"[Debug] 保存账号文件失败: {file_exc}") + add_sso_to_cpa(sso, email=email, log_callback=cli_log) + success_count += 1 + retry_count_for_slot = 0 + i += 1 + cli_log(f"[+] 注册成功: {email}") + cli_log(f"[*] 当前统计: 成功 {success_count} | 失败 {fail_count}") + if success_count > 0 and success_count % MEMORY_CLEANUP_INTERVAL == 0 and i < count: + cleanup_runtime_memory( + log_callback=cli_log, + reason=f"已成功 {success_count} 个账号,执行定期清理", + ) + except RegistrationCancelled: + cli_log("[!] 注册被停止") + break + except AccountRetryNeeded as exc: + retry_count_for_slot += 1 + if retry_count_for_slot <= max_slot_retry: + cli_log( + f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{max_slot_retry} 次: {exc}" + ) + else: + fail_count += 1 + retry_count_for_slot = 0 + i += 1 + cli_log(f"[-] 当前账号已达到最大重试次数,跳过: {exc}") + except Exception as exc: + fail_count += 1 + retry_count_for_slot = 0 + i += 1 + cli_log(f"[-] 注册失败: {exc}") + finally: + if controller.should_stop(): + break + try: + if browser is None: + start_browser(log_callback=cli_log) + else: + restart_browser(log_callback=cli_log) + # 停止后不再调用 cancel_callback,避免 finally 里二次抛出 RegistrationCancelled + time.sleep(1) + except KeyboardInterrupt: + controller.stop() + cli_log("[!] 收到 Ctrl+C,正在停止(再按一次强制中断)") + break + except RegistrationCancelled: + break + except Exception as restart_exc: + if controller.should_stop(): + break + cli_log(f"[Debug] 轮次清理/重启浏览器失败: {restart_exc}") + except KeyboardInterrupt: + controller.stop() + cli_log("[!] 收到 Ctrl+C,正在停止并清理") + except RegistrationCancelled: + cli_log("[!] 注册被停止") + except Exception as exc: + cli_log(f"[!] 任务异常: {exc}") + finally: + try: + signal.signal(signal.SIGINT, signal.SIG_IGN) + except Exception: + pass + try: + cleanup_runtime_memory(log_callback=cli_log, reason="任务结束") + except BaseException: + pass + try: + cli_log(f"[*] 任务结束。成功 {success_count} | 失败 {fail_count}") + except BaseException: + pass + try: + signal.signal(signal.SIGINT, _prev_sigint) + except Exception: + pass + + +def main_cli(): + load_config() + count = int(config.get("register_count", 1) or 1) + cli_log("[*] CLI 已加载配置") + cli_log(f"[*] 当前邮箱服务商: {config.get('email_provider', 'duckmail')} | 注册数量: {count}") + cli_log("[*] 输入 start 后开始;按 Ctrl+C 可强制停止") + try: + command = input("> ").strip().lower() + except KeyboardInterrupt: + cli_log("[!] 已取消") + return + if command != "start": + cli_log("[!] 未输入 start,已退出") + return + try: + run_registration_cli(count) + except KeyboardInterrupt: + # 清理阶段仍可能漏出,保证 CLI 干净退出 + cli_log("[!] 已停止") + + +def main(): + if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"): + main_cli() + return + root = tk.Tk() + setup_light_theme(root) + app = GrokRegisterGUI(root) + root.mainloop() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f0a06f5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +certifi==2026.4.22 +cffi==2.0.0 +charset-normalizer==3.4.7 +cssselect==1.3.0 +curl_cffi==0.13.0 +DataRecorder==3.6.2 +DownloadKit==2.0.7 +DrissionPage==4.1.1.2 +filelock==3.19.1 +idna==3.13 +lxml==6.1.0 +psutil==7.2.2 +pycparser==2.23 +requests==2.32.5 +requests-file==3.0.1 +tldextract==5.3.0 +urllib3==2.6.3 +websocket-client==1.9.0 diff --git a/sso_to_auth_json.py b/sso_to_auth_json.py new file mode 100644 index 0000000..74ed576 --- /dev/null +++ b/sso_to_auth_json.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +""" +SSO cookie → ~/.grok/auth.json 格式(纯 HTTP Device Flow) + +用法: + # 单个 / 批量 SSO,写出多个独立 auth 文件(每个可直接 cp 到 ~/.grok/auth.json) + python3 sso_to_auth_json.py --sso sso_list.txt --out-dir ./auth_out + + # 合并到一个 json(key 带 user_id 后缀,避免覆盖) + python3 sso_to_auth_json.py --sso sso_list.txt --out auth_merged.json --merge + + # 单行 sso + python3 sso_to_auth_json.py --sso-cookie 'eyJ...' --out ~/.grok/auth.json +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import secrets +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from curl_cffi import requests + +CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +OIDC_ISSUER = "https://auth.x.ai" +AUTH_KEY = f"{OIDC_ISSUER}::{CLIENT_ID}" +SCOPES = ( + "openid profile email offline_access grok-cli:access " + "api:access conversations:read conversations:write" +) + +# --- CLIProxyAPI (CPA) 扁平格式常量 ------------------------------------------ +# CPA 的 internal/auth/xai/token.go TokenStorage 读的是扁平字段。 +# Build/CLI token(scope 含 grok-cli:access)必须走 cli-chat-proxy.grok.com, +# 不能用默认 api.x.ai/v1(那是计费通道,会 402)。 +CPA_TOKEN_ENDPOINT = f"{OIDC_ISSUER}/oauth2/token" +CPA_GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1" +CPA_GROK_HEADERS = { + "X-XAI-Token-Auth": "xai-grok-cli", + "x-grok-client-version": "0.2.93", + "x-grok-client-identifier": "grok-shell", +} + + +def b64url_decode(seg: str) -> bytes: + seg += "=" * (-len(seg) % 4) + return base64.urlsafe_b64decode(seg) + + +def decode_jwt_payload(token: str) -> dict: + try: + return json.loads(b64url_decode(token.split(".")[1])) + except Exception: + return {} + + +def rfc3339_ns(ts: float | None = None) -> str: + """2026-07-10T01:00:00.000000000Z""" + if ts is None: + ts = time.time() + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + ".000000000Z" + + +def _urlopen(req, proxy: str = "", timeout: int = 15): + """urllib 请求,proxy 非空时走代理。""" + if proxy: + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy, "https": proxy}) + ) + return opener.open(req, timeout=timeout) + return urllib.request.urlopen(req, timeout=timeout) + + +def request_device_code(proxy: str = "", log=print) -> dict | None: + data = urllib.parse.urlencode({"client_id": CLIENT_ID, "scope": SCOPES}).encode() + req = urllib.request.Request( + f"{OIDC_ISSUER}/oauth2/device/code", + data=data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + with _urlopen(req, proxy=proxy, timeout=15) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + log(f" ❌ device/code HTTP {e.code}: {e.read().decode()[:200]}") + return None + + +def poll_token(device_code: str, interval: int, expires_in: int, timeout: int = 60, proxy: str = "", log=print) -> dict | None: + deadline = time.time() + min(expires_in, timeout) + while time.time() < deadline: + time.sleep(interval) + data = urllib.parse.urlencode( + { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": CLIENT_ID, + "device_code": device_code, + } + ).encode() + req = urllib.request.Request( + f"{OIDC_ISSUER}/oauth2/token", + data=data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + with _urlopen(req, proxy=proxy, timeout=15) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + err = json.loads(e.read()) + error = err.get("error", "") + if error == "authorization_pending": + continue + if error == "slow_down": + interval += 5 + continue + log(f" ❌ token: {error}") + return None + log(" ❌ 轮询超时") + return None + + +def sso_to_token(sso_cookie: str, proxy: str = "", log=print) -> dict | None: + """SSO cookie → token dict (access/refresh/expires_in)。proxy 非空时全程走代理。""" + proxies = {"http": proxy, "https": proxy} if proxy else None + s = requests.Session() + if proxies: + s.proxies = proxies + s.cookies.set("sso", sso_cookie, domain=".x.ai") + + try: + r = s.get("https://accounts.x.ai/", impersonate="chrome", timeout=15) + except Exception as e: + log(f" ❌ 网络错误: {e}") + return None + if "sign-in" in r.url or "sign-up" in r.url: + log(" ❌ sso 无效") + return None + log(" ✅ sso 有效") + + log(" 🔑 Device Flow...") + dc = request_device_code(proxy=proxy, log=log) + if not dc: + return None + log(f" 📋 user_code: {dc.get('user_code')}") + + try: + s.get(dc["verification_uri_complete"], impersonate="chrome", timeout=15) + r = s.post( + f"{OIDC_ISSUER}/oauth2/device/verify", + data={"user_code": dc["user_code"]}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + impersonate="chrome", + timeout=15, + allow_redirects=True, + ) + if "consent" not in r.url: + log(f" ❌ verify 失败: {r.url}") + return None + except Exception as e: + log(f" ❌ verify 异常: {e}") + return None + + try: + r = s.post( + f"{OIDC_ISSUER}/oauth2/device/approve", + data={ + "user_code": dc["user_code"], + "action": "allow", + "principal_type": "User", + "principal_id": "", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + impersonate="chrome", + timeout=15, + allow_redirects=True, + ) + if "done" not in r.url: + log(f" ❌ approve 失败: {r.url}") + return None + log(" ✅ 授权确认") + except Exception as e: + log(f" ❌ approve 异常: {e}") + return None + + token = poll_token( + dc["device_code"], + dc.get("interval", 5), + dc.get("expires_in", 1800), + proxy=proxy, + log=log, + ) + if not token: + return None + log( + f" ✅ access_token (expires_in={token.get('expires_in')}s)" + + (" + refresh_token" if token.get("refresh_token") else "") + ) + return token + + +def token_to_auth_entry(token: dict, email: str = "") -> tuple[str, dict]: + """ + 返回 (top_level_key, entry) + top_level_key 固定为 issuer::client_id(与 ~/.grok/auth.json 一致) + """ + access = token.get("access_token") or token.get("key") or "" + refresh = token.get("refresh_token") or "" + payload = decode_jwt_payload(access) + + user_id = payload.get("sub") or payload.get("principal_id") or "" + principal_id = payload.get("principal_id") or user_id + principal_type = payload.get("principal_type") or "User" + + expires_in = int(token.get("expires_in") or 21600) + # 优先用 JWT exp + if "exp" in payload: + expires_at = rfc3339_ns(float(payload["exp"])) + else: + expires_at = rfc3339_ns(time.time() + expires_in) + + iat = payload.get("iat") + create_time = rfc3339_ns(float(iat) if iat else time.time()) + + entry = { + "key": access, + "auth_mode": "oidc", + "create_time": create_time, + "user_id": user_id, + "email": email or "", + "principal_type": principal_type, + "principal_id": principal_id, + "refresh_token": refresh, + "expires_at": expires_at, + "oidc_issuer": OIDC_ISSUER, + "oidc_client_id": CLIENT_ID, + } + return AUTH_KEY, entry + + +def _iso_utc_from_unix(ts) -> str: + """unix 秒 → CPA 认的 RFC3339(秒级,带 Z)。""" + try: + return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception: + return "" + + +def _safe_email_for_filename(email: str) -> str: + safe = "".join(ch if ch.isalnum() or ch in "._-@" else "_" for ch in email) + return safe or "unknown" + + +def token_to_cpa_record(token: dict, email: str = "") -> dict: + """token dict → CLIProxyAPI 扁平 xai auth 记录。 + + 对齐 CPA internal/auth/xai/token.go 的 TokenStorage 字段,以及 + grok-build-auth build_cliproxyapi_auth_record 的输出。 + """ + access = token.get("access_token") or token.get("key") or "" + refresh = token.get("refresh_token") or "" + id_token = token.get("id_token") or "" + payload = decode_jwt_payload(access) + id_payload = decode_jwt_payload(id_token) if id_token else {} + + if not email: + email = id_payload.get("email") or payload.get("email") or "" + sub = payload.get("sub") or id_payload.get("sub") or "" + + # expired: 优先 access token 的 exp,其次 expires_in 推算 + expired = "" + if "exp" in payload: + expired = _iso_utc_from_unix(payload["exp"]) + elif token.get("expires_in") is not None: + try: + expired = _iso_utc_from_unix(int(time.time()) + int(token["expires_in"])) + except Exception: + expired = "" + + return { + "type": "xai", + "auth_kind": "oauth", + "email": email or "", + "sub": sub, + "access_token": access, + "refresh_token": refresh, + "id_token": id_token, + "token_type": token.get("token_type", "Bearer"), + "expires_in": token.get("expires_in", None), + "expired": expired, + "last_refresh": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "redirect_uri": "", + "token_endpoint": CPA_TOKEN_ENDPOINT, + "base_url": CPA_GROK_BASE_URL, + "disabled": False, + "headers": dict(CPA_GROK_HEADERS), + } + + +def cpa_auth_filename(record: dict) -> str: + """生成 CPA auth 文件名:xai-.json。""" + ident = str(record.get("email") or "").strip() or str(record.get("sub") or "").strip() + safe = _safe_email_for_filename(ident) + # 避免 email 本地部分已是 xai 时出现 "xai-xai..." + fname = safe if safe.lower().startswith("xai") else f"xai-{safe}" + return f"{fname}.json" + + +def write_cpa_auth(auth_dir: Path, record: dict) -> Path: + """写出 CPA 可热加载的 xai-.json(原子替换)。 + + 无 email 时用 sub(user_id) 命名,避免多个无 email 账号写成同一个 + xai-unknown.json 互相覆盖。 + """ + auth_dir.mkdir(parents=True, exist_ok=True) + path = auth_dir / cpa_auth_filename(record) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, path) + return path + + +def upload_cpa_auth_remote( + base_url: str, + management_key: str, + record: dict, + timeout: int = 30, +) -> str: + """通过 CPA Management API 上传 auth 文件到远程实例。 + + POST /v0/management/auth-files?name= + Header: Authorization: Bearer + Body: raw JSON auth record + """ + import requests + + base = str(base_url or "").strip().rstrip("/") + key = str(management_key or "").strip() + if not base: + raise ValueError("cpa_remote_url 为空") + if not key: + raise ValueError("cpa_management_key 为空") + + name = cpa_auth_filename(record) + url = f"{base}/v0/management/auth-files" + resp = requests.post( + url, + params={"name": name}, + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + }, + data=json.dumps(record, ensure_ascii=False).encode("utf-8"), + timeout=timeout, + ) + if resp.status_code >= 400: + body = (resp.text or "").strip() + if len(body) > 300: + body = body[:300] + "..." + raise RuntimeError(f"远程上传失败 HTTP {resp.status_code}: {body or resp.reason}") + return name + + +def write_auth_json(path: Path, auth_key: str, entry: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = {auth_key: entry} + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +def merge_auth_json(path: Path, auth_key: str, entry: dict, unique: bool = True) -> None: + """ + 合并写入。unique=True 时 key 变成 issuer::client_id::user_id,避免多账号互相覆盖。 + """ + path.parent.mkdir(parents=True, exist_ok=True) + existing: dict = {} + if path.exists(): + try: + existing = json.loads(path.read_text(encoding="utf-8")) + except Exception: + existing = {} + key = auth_key + if unique and entry.get("user_id"): + key = f"{auth_key}::{entry['user_id']}" + existing[key] = entry + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +def load_sso_list(path: str | None, single: str | None) -> list[str]: + if single: + return [single.strip()] + if not path: + return [] + out = [] + for line in Path(path).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # 兼容 邮箱----密码----sso + if "----" in line: + parts = line.split("----") + line = parts[-1].strip() + out.append(line) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description="SSO cookie → grok auth.json (纯 HTTP)") + ap.add_argument("--sso", metavar="FILE", help="sso 列表文件(一行一个 JWT,或 邮箱----密码----sso)") + ap.add_argument("--sso-cookie", metavar="JWT", help="单个 sso cookie") + ap.add_argument("--out", default=None, help="输出 auth.json 路径(单账号或 --merge)") + ap.add_argument( + "--out-dir", + default=None, + help="批量时每个账号写一个 {user_id}.json(可直接 cp 到 ~/.grok/auth.json)", + ) + ap.add_argument( + "--merge", + action="store_true", + help="合并到 --out,key 用 issuer::client_id::user_id", + ) + ap.add_argument("--delay", type=int, default=0, help="每个间隔秒数") + ap.add_argument("--email", default="", help="写入 entry.email(可选)") + ap.add_argument( + "--cpa-auth-dir", + default=None, + help="额外写出 CLIProxyAPI 扁平格式 xai-.json 到该目录(CPA 热加载,可直接指向 CPA 挂载目录)", + ) + ap.add_argument( + "--cpa-remote-url", + default=None, + help="远程 CPA 地址,如 http://IP:8317;配合 --cpa-management-key 通过 Management API 上传", + ) + ap.add_argument( + "--cpa-management-key", + default=None, + help="远程 CPA 管理密钥(remote-management.secret-key 明文)", + ) + ap.add_argument("--proxy", default="", help="device-flow 走代理,如 http://127.0.0.1:7890") + args = ap.parse_args() + + cookies = load_sso_list(args.sso, args.sso_cookie) + if not cookies: + ap.error("需要 --sso 或 --sso-cookie") + + if args.cpa_remote_url and not args.cpa_management_key: + ap.error("使用 --cpa-remote-url 时必须同时提供 --cpa-management-key") + if args.cpa_management_key and not args.cpa_remote_url: + ap.error("使用 --cpa-management-key 时必须同时提供 --cpa-remote-url") + + if len(cookies) > 1 and not args.out_dir and not args.merge: + # 默认批量写目录 + args.out_dir = args.out_dir or "./auth_out" + print(f"批量模式默认 --out-dir {args.out_dir}") + + # 只指定 CPA 目标时不再默认写官方 ~/.grok/auth.json + if ( + args.out is None + and args.out_dir is None + and not args.cpa_auth_dir + and not args.cpa_remote_url + and len(cookies) == 1 + ): + args.out = str(Path.home() / ".grok" / "auth.json") + + print(f"🚀 SSO → auth.json: {len(cookies)} 个, delay={args.delay}s") + ok = 0 + fail = 0 + + for i, sso in enumerate(cookies, 1): + print(f"\n{'=' * 60}\n[{i}/{len(cookies)}] ...\n{'=' * 60}") + try: + token = sso_to_token(sso, proxy=args.proxy) + if not token: + fail += 1 + print(f" ❌ [{i}] 失败") + continue + key, entry = token_to_auth_entry(token, email=args.email) + uid = entry.get("user_id") or secrets.token_hex(4) + + if args.out_dir: + p = Path(args.out_dir) / f"{uid}.json" + write_auth_json(p, key, entry) + print(f" 💾 {p}") + if args.out: + if args.merge or len(cookies) > 1: + merge_auth_json(Path(args.out), key, entry, unique=True) + print(f" 💾 merge → {args.out}") + else: + write_auth_json(Path(args.out), key, entry) + print(f" 💾 {args.out}") + + if args.cpa_auth_dir or args.cpa_remote_url: + record = token_to_cpa_record(token, email=args.email) + if args.cpa_auth_dir: + cp = write_cpa_auth(Path(args.cpa_auth_dir), record) + print(f" 💾 CPA 本地 → {cp}") + if args.cpa_remote_url: + name = upload_cpa_auth_remote( + args.cpa_remote_url, + args.cpa_management_key, + record, + ) + print(f" 💾 CPA 远程 → {args.cpa_remote_url.rstrip('/')}/.../{name}") + + ok += 1 + print(f" ✅ [{i}] 完成 user_id={uid[:12]}...") + except Exception as e: + fail += 1 + print(f" ❌ [{i}] 异常: {e}") + + if args.delay > 0 and i < len(cookies): + time.sleep(args.delay) + + print(f"\n{'=' * 60}\n📊 完成: {ok}/{len(cookies)} 成功, {fail} 失败") + return 0 if fail == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cloudflare_admin_api.py b/tests/test_cloudflare_admin_api.py new file mode 100644 index 0000000..7611504 --- /dev/null +++ b/tests/test_cloudflare_admin_api.py @@ -0,0 +1,155 @@ +import unittest +from unittest.mock import patch + +import cf_mail_debug +import grok_register_ttk as app + + +class DummyResponse: + def __init__(self, payload): + self._payload = payload + self.text = "" + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class CloudflareAdminCreateTests(unittest.TestCase): + def setUp(self): + self.original_config = app.config.copy() + self.original_cf_domain_index = app._cf_domain_index + app._cf_domain_index = 0 + + def tearDown(self): + app.config = self.original_config + app._cf_domain_index = self.original_cf_domain_index + + def test_default_config_keeps_cloudflare_temp_email_new_address(self): + app.config = app.DEFAULT_CONFIG.copy() + captured = {} + + def fake_post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return DummyResponse({"address": "anon@example.com", "jwt": "default-jwt"}) + + with patch.object(app, "http_post", side_effect=fake_post): + address, jwt = app.cloudflare_create_temp_address("https://temp-mail.example.com") + + self.assertEqual(address, "anon@example.com") + self.assertEqual(jwt, "default-jwt") + self.assertEqual(captured["url"], "https://temp-mail.example.com/api/new_address") + self.assertEqual(captured["json"], {}) + self.assertEqual(captured["headers"], {"Content-Type": "application/json"}) + + def test_app_uses_admin_new_address_with_x_admin_auth(self): + app.config.update({ + "cloudflare_api_key": "admin-secret", + "cloudflare_auth_mode": "x-admin-auth", + "cloudflare_path_accounts": "/admin/new_address", + "defaultDomains": "vitassk.com", + }) + captured = {} + + def fake_post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return DummyResponse({"address": "adminuser@vitassk.com", "jwt": "address-jwt"}) + + with patch.object(app, "generate_username", return_value="adminuser"), \ + patch.object(app, "http_post", side_effect=fake_post): + address, jwt = app.cloudflare_create_temp_address("https://temp-mail.ikun.day") + + self.assertEqual(address, "adminuser@vitassk.com") + self.assertEqual(jwt, "address-jwt") + self.assertEqual(captured["url"], "https://temp-mail.ikun.day/admin/new_address") + self.assertEqual(captured["json"], { + "name": "adminuser", + "domain": "vitassk.com", + "enablePrefix": True, + }) + self.assertEqual(captured["headers"]["Content-Type"], "application/json") + self.assertEqual(captured["headers"]["x-admin-auth"], "admin-secret") + + def test_app_keeps_anonymous_new_address_with_none_auth(self): + app.config.update({ + "cloudflare_api_key": "", + "cloudflare_auth_mode": "none", + "cloudflare_custom_auth": "", + "cloudflare_path_accounts": "/api/new_address", + "defaultDomains": "vitassk.com", + }) + captured = {} + + def fake_post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return DummyResponse({"address": "anon@vitassk.com", "jwt": "anon-jwt"}) + + with patch.object(app, "http_post", side_effect=fake_post): + address, jwt = app.cloudflare_create_temp_address("https://temp-mail.ikun.day") + + self.assertEqual(address, "anon@vitassk.com") + self.assertEqual(jwt, "anon-jwt") + self.assertEqual(captured["url"], "https://temp-mail.ikun.day/api/new_address") + self.assertEqual(captured["json"], {"domain": "vitassk.com"}) + self.assertEqual(captured["headers"], {"Content-Type": "application/json"}) + + def test_app_injects_custom_auth_on_anonymous_new_address(self): + app.config.update({ + "cloudflare_api_key": "", + "cloudflare_auth_mode": "none", + "cloudflare_custom_auth": "global-pass", + "cloudflare_path_accounts": "/api/new_address", + "defaultDomains": "vitassk.com", + }) + captured = {} + + def fake_post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return DummyResponse({"address": "anon@vitassk.com", "jwt": "anon-jwt"}) + + with patch.object(app, "http_post", side_effect=fake_post): + app.cloudflare_create_temp_address("https://temp-mail.ikun.day") + + self.assertEqual(captured["headers"], { + "Content-Type": "application/json", + "x-custom-auth": "global-pass", + }) + + def test_debug_tool_can_create_address_through_admin_api(self): + captured = {} + + def fake_post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return DummyResponse({"address": "debuguser@vitassk.com", "jwt": "debug-jwt"}) + + with patch.object(cf_mail_debug.requests, "post", side_effect=fake_post): + address, jwt = cf_mail_debug.create_address( + "https://temp-mail.ikun.day", + auth_mode="x-admin-auth", + api_key="admin-secret", + create_path="/admin/new_address", + domain="vitassk.com", + name="debuguser", + ) + + self.assertEqual(address, "debuguser@vitassk.com") + self.assertEqual(jwt, "debug-jwt") + self.assertEqual(captured["url"], "https://temp-mail.ikun.day/admin/new_address") + self.assertEqual(captured["json"], { + "name": "debuguser", + "domain": "vitassk.com", + "enablePrefix": True, + }) + self.assertEqual(captured["headers"]["Content-Type"], "application/json") + self.assertEqual(captured["headers"]["x-admin-auth"], "admin-secret") + + +if __name__ == "__main__": + unittest.main()