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 @@
+
+
+[](https://github.com/Git-creat7/grokRegister-cpa)
+
+批量注册 Grok 账号,注册成功后自动把 OAuth 凭证写入 [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI):支持本地 auth 目录热加载,也支持 Management API 远程上传。
+
+
+
+
+
+
+
+
+
+
+---
+
+> 仅用于自动化流程研究、测试环境验证和个人学习。请遵守目标网站服务条款、当地法律法规与第三方服务限制。
+
+## 核心流程
+
+```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()