- wait_for_sso_cookie 不再吞掉 RegistrationCancelled - GUI 轮次 finally 对齐 CLI 的重启/停止保护 - CPA 本地写入与远程上传互不阻断 - 修复 YYDS/DuckMail 异常与日志中文乱码
3184 lines
124 KiB
Python
Executable File
3184 lines
124 KiB
Python
Executable File
#!/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-<email>.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:
|
||
try:
|
||
path = _s2cpa.write_cpa_auth(_s2cpa.Path(auth_dir), record)
|
||
_cpa_log(f"已写入本地 {path}")
|
||
except Exception as local_exc:
|
||
_cpa_log(f"本地写入失败: {local_exc}")
|
||
if remote_url:
|
||
try:
|
||
name = _s2cpa.upload_cpa_auth_remote(remote_url, management_key, record)
|
||
_cpa_log(f"已上传远程 {remote_url.rstrip('/')}/.../{name}")
|
||
except Exception as remote_exc:
|
||
_cpa_log(f"远程上传失败: {remote_exc}")
|
||
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"<binary {len(raw)} bytes>"
|
||
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 RegistrationCancelled:
|
||
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
|
||
try:
|
||
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:
|
||
break
|
||
except Exception as restart_exc:
|
||
if self.should_stop():
|
||
break
|
||
self.log(f"[Debug] 轮次清理/重启浏览器失败: {restart_exc}")
|
||
except RegistrationCancelled:
|
||
self.log("[!] 注册被用户停止")
|
||
except Exception as exc:
|
||
self.log(f"[!] 任务异常: {exc}")
|
||
finally:
|
||
try:
|
||
stop_browser()
|
||
except BaseException:
|
||
pass
|
||
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
|