- Major refactor of grok_register_ttk.py: improved flow, error handling, logging - Update config.example.json and config.json with new fields - Replace turnstilePatch/content.js with new script.js approach - Update start.sh for better Chromium/Turnstile handling - Remove deprecated turnstilePatch/content.js
5883 lines
231 KiB
Python
5883 lines
231 KiB
Python
#!/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 queue
|
||
import secrets
|
||
import struct
|
||
import random
|
||
import re
|
||
import string
|
||
import json
|
||
import imaplib
|
||
import email as email_lib
|
||
from datetime import timezone
|
||
from email.utils import parsedate_to_datetime
|
||
|
||
from DrissionPage import Chromium, ChromiumOptions
|
||
from DrissionPage.errors import PageDisconnectedError
|
||
from curl_cffi import requests
|
||
|
||
|
||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||
|
||
DEFAULT_CONFIG = {
|
||
"duckmail_api_key": "",
|
||
"cloudflare_api_base": "",
|
||
"cloudflare_api_key": "",
|
||
"cloudflare_auth_mode": "bearer",
|
||
"cloudflare_path_domains": "/domains",
|
||
"cloudflare_path_accounts": "/accounts",
|
||
"cloudflare_path_token": "/token",
|
||
"cloudflare_path_messages": "/messages",
|
||
"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",
|
||
"grok2api_auto_add_local": True,
|
||
"grok2api_local_token_file": "",
|
||
"grok2api_pool_name": "ssoBasic",
|
||
"grok2api_auto_add_remote": False,
|
||
"grok2api_remote_base": "",
|
||
"grok2api_remote_app_key": "",
|
||
"register_threads": 1,
|
||
"thread_start_interval": 0.8,
|
||
"show_tutorial_on_start": True,
|
||
"cloudmail_url": "",
|
||
"cloudmail_admin_email": "",
|
||
"cloudmail_password": "",
|
||
"cpa_gui_close_mint_browser": True,
|
||
"browser_headless": "auto",
|
||
"browser_prefer": "auto",
|
||
"browser_path": "",
|
||
"hotmail_accounts_file": "mail_credentials.txt",
|
||
"hotmail_alias_mode": "random",
|
||
"hotmail_alias_random_length": 8,
|
||
"hotmail_alias_random_max_attempts": 200,
|
||
"hotmail_max_aliases_per_account": 5,
|
||
"hotmail_poll_interval": 5,
|
||
"hotmail_recent_seconds": 900,
|
||
"hotmail_imap_hosts": "outlook.office365.com,imap-mail.outlook.com",
|
||
"hotmail_imap_last_n": 30,
|
||
"hotmail_require_recipient_match": True,
|
||
}
|
||
|
||
config = DEFAULT_CONFIG.copy()
|
||
_cf_domain_index = 0
|
||
# CloudMail 公开 token 单例(多线程共享,避免并发覆盖)
|
||
_cloudmail_public_token = None
|
||
_cloudmail_public_token_lock = threading.Lock()
|
||
_cpa_gui_export_lock = threading.Lock()
|
||
_hotmail_accounts_cache = None
|
||
_hotmail_accounts_mtime = None
|
||
_hotmail_accounts_lock = threading.Lock()
|
||
_hotmail_selection_lock = threading.Lock()
|
||
_hotmail_reserved_aliases = set()
|
||
_hotmail_token_map = {}
|
||
_hotmail_refresh_locks = {}
|
||
_hotmail_refresh_locks_lock = threading.Lock()
|
||
|
||
|
||
|
||
# ── 邮箱追踪 ──
|
||
|
||
_EMAILS_USED_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails_used.txt")
|
||
_EMAILS_ERROR_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails_error.txt")
|
||
_email_track_lock = threading.Lock()
|
||
|
||
|
||
def mark_used(email: str, password: str = ""):
|
||
"""记录成功注册的邮箱,防止重复使用。"""
|
||
with _email_track_lock:
|
||
with open(_EMAILS_USED_FILE, "a", encoding="utf-8") as f:
|
||
f.write(f"{email}----{password}----ok\n")
|
||
try:
|
||
_hotmail_release_alias(email)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def mark_error(email: str, password: str = "", reason: str = ""):
|
||
"""记录失败邮箱及原因,避免重试烂邮箱。"""
|
||
with _email_track_lock:
|
||
with open(_EMAILS_ERROR_FILE, "a", encoding="utf-8") as f:
|
||
f.write(f"{email}----{password}----{reason}\n")
|
||
try:
|
||
_hotmail_release_alias(email)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def is_email_used(email: str) -> bool:
|
||
"""检查邮箱是否已被使用或标记为失败。"""
|
||
email_lower = email.strip().lower()
|
||
for fpath in (_EMAILS_USED_FILE, _EMAILS_ERROR_FILE):
|
||
if os.path.exists(fpath):
|
||
with open(fpath, encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line and not line.startswith("#"):
|
||
parts = line.split("----")
|
||
if parts and parts[0].strip().lower() == email_lower:
|
||
return True
|
||
return False
|
||
|
||
|
||
# ── 页面状态快照 ──
|
||
|
||
# ── CLI / batch performance knobs (register_cli may mutate) ──
|
||
PERF_FLAGS = {
|
||
"fast": False, # scale down human_sleep
|
||
"sleep_scale": 1.0, # multiply all human_sleep means
|
||
"skip_debug_io": False, # skip dump_state / take_screenshot
|
||
"cookie_snapshot": True, # save_cookies_snapshot
|
||
"async_side_effects": True, # grok2api / cookie snapshot in background
|
||
"browser_reuse": True, # clear_session instead of quit between accounts
|
||
"browser_recycle_every": 25, # full quit+recreate after N successful reuses
|
||
}
|
||
|
||
_side_effect_pool = None
|
||
|
||
|
||
def _get_side_effect_pool():
|
||
global _side_effect_pool
|
||
if _side_effect_pool is None:
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
_side_effect_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="sidefx")
|
||
return _side_effect_pool
|
||
|
||
|
||
def configure_perf(**kwargs):
|
||
"""Update PERF_FLAGS from CLI. Unknown keys ignored."""
|
||
for k, v in kwargs.items():
|
||
if k in PERF_FLAGS:
|
||
PERF_FLAGS[k] = v
|
||
|
||
|
||
_SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots")
|
||
|
||
|
||
def dump_state(page, tag: str = ""):
|
||
"""打印当前页面状态:URL、可见按钮文本、输入框类型。"""
|
||
if PERF_FLAGS.get("skip_debug_io"):
|
||
return
|
||
try:
|
||
info = page.run_js("""
|
||
const btns = [...document.querySelectorAll('button')]
|
||
.map(b => (b.innerText || '').trim())
|
||
.filter(t => t)
|
||
.slice(0, 20);
|
||
const inputs = [...document.querySelectorAll('input,textarea')]
|
||
.map(i => (i.type || 'text') + '/' + (i.placeholder || i.name || i.getAttribute('data-testid') || ''))
|
||
.slice(0, 15);
|
||
return {url: location.href, title: document.title, btns: btns, inputs: inputs};
|
||
""")
|
||
if not info:
|
||
print(f" [state:{tag}] page context not ready (None)")
|
||
return
|
||
print(f" [state:{tag}] url: {info.get('url', '?')}")
|
||
print(f" [state:{tag}] btns: {info.get('btns', [])}")
|
||
print(f" [state:{tag}] inputs: {info.get('inputs', [])}")
|
||
except Exception as e:
|
||
print(f" [state:{tag}] dump_state err: {e}")
|
||
|
||
|
||
def take_screenshot(page, tag: str = ""):
|
||
"""捕获当前页面截图并保存到 screenshots/ 目录。"""
|
||
if PERF_FLAGS.get("skip_debug_io"):
|
||
return
|
||
try:
|
||
os.makedirs(_SCREENSHOT_DIR, exist_ok=True)
|
||
ts = datetime.datetime.now().strftime("%H%M%S")
|
||
path = os.path.join(_SCREENSHOT_DIR, f"{ts}_{tag}.png")
|
||
page.get_screenshot(path=path)
|
||
print(f" [screenshot] saved: {path}")
|
||
except Exception as e:
|
||
print(f" [screenshot] err: {e}")
|
||
|
||
|
||
# ── 超时守卫 ──
|
||
|
||
REGISTER_TIMEOUT = 180 # 单次注册总超时(秒)
|
||
|
||
|
||
class TimeoutError(Exception):
|
||
pass
|
||
|
||
|
||
def check_timeout(start_time: float):
|
||
"""检查是否超过总超时时间。"""
|
||
elapsed = time.time() - start_time
|
||
if elapsed > REGISTER_TIMEOUT:
|
||
raise TimeoutError(f"注册超时 ({REGISTER_TIMEOUT}s, 已用 {elapsed:.0f}s)")
|
||
|
||
|
||
# ── 全量 cookie 保存 ──
|
||
|
||
_COOKIE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies", "grok")
|
||
|
||
|
||
def save_cookies_snapshot(page, tag: str = "", email: str = ""):
|
||
"""保存当前浏览器上下文的全量 cookie 快照。"""
|
||
if not PERF_FLAGS.get("cookie_snapshot", True):
|
||
return
|
||
try:
|
||
browser = _get_browser()
|
||
if not browser:
|
||
return
|
||
os.makedirs(_COOKIE_DIR, exist_ok=True)
|
||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
cookies = browser.cookies()
|
||
data = {
|
||
"ts": ts,
|
||
"tag": tag,
|
||
"email": email,
|
||
"url": page.url if page else "",
|
||
"cookies": cookies,
|
||
}
|
||
path = os.path.join(_COOKIE_DIR, f"full_{ts}_{tag}.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
print(f" [cookies] saved: {path} ({len(cookies)} cookies)")
|
||
except Exception as e:
|
||
print(f" [cookies] save err: {e}")
|
||
|
||
|
||
# ── .env 加载 ──
|
||
|
||
|
||
def load_env():
|
||
"""从 .env 文件加载环境变量(零依赖)。
|
||
只在 os.environ 中尚未设置该 KEY 时填入(真实环境变量优先)。
|
||
"""
|
||
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||
if not os.path.isfile(env_path):
|
||
return
|
||
try:
|
||
with open(env_path, encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, _, val = line.partition("=")
|
||
key = key.strip()
|
||
val = val.strip().strip('"').strip("'")
|
||
if key and key not in os.environ:
|
||
os.environ[key] = val
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class RegistrationCancelled(Exception):
|
||
pass
|
||
|
||
|
||
def load_config():
|
||
load_env()
|
||
global config
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||
loaded = json.load(f)
|
||
# Allow "// comment" keys in config.example.json / config.json templates.
|
||
if isinstance(loaded, dict):
|
||
loaded = {
|
||
k: v
|
||
for k, v in loaded.items()
|
||
if not (isinstance(k, str) and (k.startswith("//") or k.startswith("#")))
|
||
}
|
||
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", "bearer") or "bearer").lower()
|
||
|
||
|
||
def get_cloudflare_path(key, default_path):
|
||
raw = str(config.get(key, default_path) or default_path).strip()
|
||
if not raw.startswith("/"):
|
||
raw = "/" + raw
|
||
return raw
|
||
|
||
|
||
def cloudflare_build_headers(content_type=False):
|
||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key:
|
||
if mode == "x-api-key":
|
||
headers["X-API-Key"] = key
|
||
elif mode != "none":
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
return headers
|
||
|
||
|
||
def cloudflare_apply_auth_params(params=None):
|
||
merged = dict(params or {})
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key and mode == "query-key":
|
||
merged["key"] = key
|
||
return merged
|
||
|
||
|
||
def _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 v1.8.x: POST /api/new_address -> {address,jwt}"""
|
||
global _cf_domain_index
|
||
url = f"{api_base}/api/new_address"
|
||
payload = {}
|
||
try:
|
||
# 在多个域名之间轮换,降低单域偶发不收件导致的失败率
|
||
domains = [x.strip() for x in re.split(r"[,,\s]+", str(config.get("defaultDomains", "") or "")) if x.strip()]
|
||
if domains:
|
||
payload["domain"] = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
except Exception:
|
||
pass
|
||
resp = http_post(url, json=payload, headers={"Content-Type": "application/json"})
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare /api/new_address 返回非JSON: {resp.text[:300]}")
|
||
address = data.get("address")
|
||
jwt = data.get("jwt")
|
||
if not address or not jwt:
|
||
raise Exception(f"Cloudflare /api/new_address 缺少 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 resolve_grok2api_local_token_file():
|
||
configured = str(config.get("grok2api_local_token_file", "") or "").strip()
|
||
if configured:
|
||
return configured
|
||
return r"D:\注册机\3255d5ee6e702db9220a897df64635a1ec9df644\vendor\grok2api\data\token.json"
|
||
|
||
|
||
def _normalize_sso_token(raw_token):
|
||
token = str(raw_token or "").strip()
|
||
if token.startswith("sso="):
|
||
token = token[4:]
|
||
return token
|
||
|
||
|
||
def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
|
||
token = _normalize_sso_token(raw_token)
|
||
if not token:
|
||
return False
|
||
token_file = resolve_grok2api_local_token_file()
|
||
pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip()
|
||
if not pool_name:
|
||
pool_name = "ssoBasic"
|
||
os.makedirs(os.path.dirname(token_file), exist_ok=True)
|
||
data = {}
|
||
if os.path.exists(token_file):
|
||
try:
|
||
with open(token_file, "r", encoding="utf-8") as f:
|
||
data = json.load(f) or {}
|
||
except Exception:
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
pool = data.get(pool_name)
|
||
if not isinstance(pool, list):
|
||
pool = []
|
||
existing = set()
|
||
for item in pool:
|
||
if isinstance(item, str):
|
||
existing.add(_normalize_sso_token(item))
|
||
elif isinstance(item, dict):
|
||
existing.add(_normalize_sso_token(item.get("token", "")))
|
||
if token in existing:
|
||
if log_callback:
|
||
log_callback(f"[*] grok2api 本地池已存在 token: {pool_name}")
|
||
return True
|
||
entry = {"token": token, "tags": ["auto-register"], "note": email}
|
||
pool.append(entry)
|
||
data[pool_name] = pool
|
||
with open(token_file, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
if log_callback:
|
||
log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})")
|
||
return True
|
||
|
||
|
||
def add_token_to_grok2api_remote_pool(raw_token, email="", log_callback=None):
|
||
token = _normalize_sso_token(raw_token)
|
||
if not token:
|
||
return False
|
||
base = str(config.get("grok2api_remote_base", "") or "").strip().rstrip("/")
|
||
app_key = str(os.environ.get("GROK2API_APP_KEY") or config.get("grok2api_remote_app_key", "") or "").strip()
|
||
pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() or "ssoBasic"
|
||
if not base or not app_key:
|
||
if log_callback:
|
||
log_callback("[Debug] grok2api 远端未配置 base/app_key,跳过")
|
||
return False
|
||
headers = {"Content-Type": "application/json"}
|
||
query = {"app_key": app_key, "auto_nsfw": "true"}
|
||
pool_map = {"ssoBasic": "basic", "ssoSuper": "super"}
|
||
remote_pool = pool_map.get(pool_name, "basic")
|
||
# 优先使用 add 接口,避免全量覆盖远端池
|
||
try:
|
||
add_payload = {"tokens": [token], "pool": remote_pool, "tags": ["auto-register"]}
|
||
resp_add = http_post(
|
||
f"{base}/tokens/add",
|
||
headers=headers,
|
||
params=query,
|
||
json=add_payload,
|
||
timeout=8,
|
||
proxies={},
|
||
)
|
||
resp_add.raise_for_status()
|
||
if log_callback:
|
||
log_callback(f"[+] 已写入 grok2api 远端池: {pool_name} ({base}/tokens/add)")
|
||
return True
|
||
except Exception as add_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] /tokens/add 写入失败,尝试 /tokens 全量模式: {add_exc}")
|
||
|
||
# 兜底:旧版全量保存接口
|
||
current = {}
|
||
try:
|
||
resp = http_get(f"{base}/tokens", headers=headers, params=query, timeout=6, proxies={})
|
||
if resp.status_code == 200:
|
||
payload = resp.json()
|
||
current = payload.get("tokens", {}) if isinstance(payload, dict) else {}
|
||
except Exception:
|
||
current = {}
|
||
if not isinstance(current, dict):
|
||
current = {}
|
||
pool = current.get(pool_name)
|
||
if not isinstance(pool, list):
|
||
pool = []
|
||
existing = set()
|
||
for item in pool:
|
||
if isinstance(item, str):
|
||
existing.add(_normalize_sso_token(item))
|
||
elif isinstance(item, dict):
|
||
existing.add(_normalize_sso_token(item.get("token", "")))
|
||
if token not in existing:
|
||
pool.append({"token": token, "tags": ["auto-register"], "note": email})
|
||
current[pool_name] = pool
|
||
resp2 = http_post(f"{base}/tokens", headers=headers, params=query, json=current, timeout=8, proxies={})
|
||
resp2.raise_for_status()
|
||
if log_callback:
|
||
log_callback(f"[+] 已写入 grok2api 远端池: {pool_name} ({base}/tokens)")
|
||
return True
|
||
|
||
|
||
def _add_token_to_grok2api_pools_sync(raw_token, email="", log_callback=None):
|
||
# SSO 账本只写 accounts_cli.txt;不再本地备份 tokens/grok/
|
||
if config.get("grok2api_auto_add_local", True):
|
||
try:
|
||
add_token_to_grok2api_local_pool(raw_token, email=email, log_callback=log_callback)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 写入 grok2api 本地池失败: {exc}")
|
||
if config.get("grok2api_auto_add_remote", False):
|
||
try:
|
||
add_token_to_grok2api_remote_pool(raw_token, email=email, log_callback=log_callback)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 写入 grok2api 远端池失败: {exc}")
|
||
|
||
|
||
def add_token_to_grok2api_pools(raw_token, email="", log_callback=None):
|
||
"""Push SSO into grok2api pools. Async by default so register path never blocks on dead :8000."""
|
||
if PERF_FLAGS.get("async_side_effects", True):
|
||
def _job():
|
||
try:
|
||
_add_token_to_grok2api_pools_sync(raw_token, email=email, log_callback=log_callback)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] grok2api side-effect 异常: {exc}")
|
||
try:
|
||
_get_side_effect_pool().submit(_job)
|
||
if log_callback:
|
||
log_callback("[*] grok2api 池写入已异步提交")
|
||
return
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 异步提交失败,同步写入: {exc}")
|
||
_add_token_to_grok2api_pools_sync(raw_token, email=email, log_callback=log_callback)
|
||
|
||
|
||
# Base flags always safe. Keep close to Git-creat7/grokRegister-cpa which only
|
||
# uses auto_port + extension (no slim flags) on real headed desktops.
|
||
CHROMIUM_BASE_FLAGS = [
|
||
"--no-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
# Keep images ON: Turnstile/CF widgets often need them; was a common fail cause.
|
||
"--mute-audio",
|
||
"--no-first-run",
|
||
"--disable-blink-features=AutomationControlled",
|
||
]
|
||
|
||
# Extra flags only for true headless (no DISPLAY). Under Xvfb/headed these can
|
||
# blank CF widgets (iframe=0 / token=0) — especially --disable-software-rasterizer.
|
||
CHROMIUM_HEADLESS_EXTRA_FLAGS = [
|
||
"--disable-gpu",
|
||
"--disable-software-rasterizer",
|
||
"--disable-background-networking",
|
||
]
|
||
|
||
# Back-compat alias used by older call sites / register_cli patches
|
||
CHROMIUM_SLIM_FLAGS = CHROMIUM_BASE_FLAGS + CHROMIUM_HEADLESS_EXTRA_FLAGS
|
||
|
||
# Headless / display helpers (Linux servers without desktop)
|
||
_HEADLESS_HINT_SHOWN = False
|
||
_HEADLESS_HINT_LOCK = threading.Lock()
|
||
|
||
|
||
def has_display() -> bool:
|
||
"""Return True if a GUI display is likely available for Chromium.
|
||
|
||
- Windows: always True (Win32 GUI subsystem).
|
||
- Linux/macOS: need DISPLAY or WAYLAND_DISPLAY (Xvfb counts as DISPLAY).
|
||
"""
|
||
if sys.platform == "win32":
|
||
return True
|
||
display = (os.environ.get("DISPLAY") or "").strip()
|
||
wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip()
|
||
return bool(display or wayland)
|
||
|
||
|
||
def _truthy_headless(val) -> bool | None:
|
||
"""Parse headless preference. True/False explicit; None means auto."""
|
||
if val is None:
|
||
return None
|
||
if isinstance(val, bool):
|
||
return val
|
||
s = str(val).strip().lower()
|
||
if s in ("", "auto", "default", "detect"):
|
||
return None
|
||
if s in ("1", "true", "yes", "on", "headless"):
|
||
return True
|
||
if s in ("0", "false", "no", "off", "headed", "gui"):
|
||
return False
|
||
return None
|
||
|
||
|
||
def resolve_headless_preference(preference=None) -> bool | None:
|
||
"""Resolve headless preference from arg → env → config → auto.
|
||
|
||
Returns:
|
||
True = force headless
|
||
False = force headed
|
||
None = auto (headless when no DISPLAY)
|
||
"""
|
||
if preference is not None:
|
||
parsed = _truthy_headless(preference)
|
||
if parsed is not None or str(preference).strip().lower() in ("", "auto", "default", "detect"):
|
||
return parsed
|
||
env_raw = (os.environ.get("BROWSER_HEADLESS") or os.environ.get("GROK_HEADLESS") or "").strip()
|
||
if env_raw:
|
||
return _truthy_headless(env_raw)
|
||
try:
|
||
cfg_val = config.get("browser_headless", "auto") if isinstance(config, dict) else "auto"
|
||
except Exception:
|
||
cfg_val = "auto"
|
||
return _truthy_headless(cfg_val)
|
||
|
||
|
||
def should_use_headless(preference=None, *, log_hint: bool = True) -> bool:
|
||
"""Decide whether Chromium should start headless.
|
||
|
||
Auto (default): headless when no DISPLAY/WAYLAND_DISPLAY.
|
||
Force headed without display is impossible — still headless + warn.
|
||
"""
|
||
pref = resolve_headless_preference(preference)
|
||
display_ok = has_display()
|
||
if pref is True:
|
||
if log_hint:
|
||
log_headless_mode(forced=True, auto=False, display_ok=display_ok)
|
||
return True
|
||
if pref is False:
|
||
if display_ok:
|
||
return False
|
||
# Cannot start headed without display — force headless
|
||
if log_hint:
|
||
log_headless_mode(forced=True, auto=True, display_ok=False, wanted_headed=True)
|
||
return True
|
||
# auto
|
||
use = not display_ok
|
||
if use and log_hint:
|
||
log_headless_mode(forced=False, auto=True, display_ok=False)
|
||
return use
|
||
|
||
|
||
def log_headless_mode(
|
||
*,
|
||
forced: bool = False,
|
||
auto: bool = False,
|
||
display_ok: bool = False,
|
||
wanted_headed: bool = False,
|
||
) -> None:
|
||
"""Print one-shot guidance for headless / Xvfb on servers."""
|
||
global _HEADLESS_HINT_SHOWN
|
||
with _HEADLESS_HINT_LOCK:
|
||
if _HEADLESS_HINT_SHOWN:
|
||
return
|
||
_HEADLESS_HINT_SHOWN = True
|
||
disp = (os.environ.get("DISPLAY") or "").strip() or "(unset)"
|
||
wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip() or "(unset)"
|
||
if wanted_headed and not display_ok:
|
||
print(
|
||
f"[!] browser_headless=false/--headed 但无图形界面 "
|
||
f"(DISPLAY={disp}, WAYLAND_DISPLAY={wayland}),仍强制 headless,否则无法启动。",
|
||
flush=True,
|
||
)
|
||
elif auto and not display_ok:
|
||
print(
|
||
f"[*] 无图形界面 (DISPLAY={disp}),已自动启用 --headless=new。",
|
||
flush=True,
|
||
)
|
||
elif forced:
|
||
print("[*] 已强制启用 headless 浏览器。", flush=True)
|
||
else:
|
||
return
|
||
print(
|
||
" 提示: headless 更容易被 Cloudflare/Turnstile 拦截;无头服务器推荐虚拟显示:\n"
|
||
" sudo apt install -y xvfb\n"
|
||
" xvfb-run -a uv run python -u register_cli.py --extra 1 --threads 1\n"
|
||
" 或常驻: Xvfb :99 -screen 0 1920x1080x24 & export DISPLAY=:99\n"
|
||
" 强制无头: config browser_headless=true / env BROWSER_HEADLESS=1 / CLI --headless\n"
|
||
" 强制有头: config browser_headless=false / CLI --headed(需 DISPLAY 或 xvfb-run)",
|
||
flush=True,
|
||
)
|
||
|
||
|
||
def apply_headless_to_options(options, headless: bool = True) -> None:
|
||
"""Apply headless flags to ChromiumOptions (DrissionPage)."""
|
||
if not headless:
|
||
try:
|
||
options.headless(False)
|
||
except Exception:
|
||
pass
|
||
return
|
||
try:
|
||
options.headless(True)
|
||
except Exception:
|
||
try:
|
||
options.set_argument("--headless=new")
|
||
except Exception:
|
||
try:
|
||
options.set_argument("--headless")
|
||
except Exception:
|
||
pass
|
||
# Extra flags commonly needed on Linux servers
|
||
for flag in ("--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"):
|
||
try:
|
||
options.set_argument(flag)
|
||
except Exception:
|
||
pass
|
||
|
||
# Browser discovery order is decided by resolve_browser_path():
|
||
# - default (auto): prefer Chromium so turnstilePatch can load (--load-extension)
|
||
# - Google Chrome blocks unpacked extensions; CDP stealth alone often fails CF on servers
|
||
# - config browser_prefer: auto | chromium | chrome
|
||
# - config browser_path: absolute override
|
||
def _windows_browser_candidates() -> tuple[str, ...]:
|
||
"""Windows Chrome/Chromium paths (Program Files + common Playwright installs)."""
|
||
if sys.platform != "win32":
|
||
return ()
|
||
out: list[str] = []
|
||
local = os.environ.get("LOCALAPPDATA") or ""
|
||
pf = os.environ.get("PROGRAMFILES") or r"C:\Program Files"
|
||
pf86 = os.environ.get("PROGRAMFILES(X86)") or r"C:\Program Files (x86)"
|
||
# Prefer Chromium-like (can load unpacked extensions) over Google Chrome
|
||
if local:
|
||
# ms-playwright chromium-* / chrome-win64
|
||
try:
|
||
pw = os.path.join(local, "ms-playwright")
|
||
if os.path.isdir(pw):
|
||
for name in sorted(os.listdir(pw), reverse=True):
|
||
if not name.lower().startswith("chromium"):
|
||
continue
|
||
cand = os.path.join(pw, name, "chrome-win64", "chrome.exe")
|
||
if os.path.isfile(cand):
|
||
out.append(cand)
|
||
except Exception:
|
||
pass
|
||
out.append(os.path.join(local, "Chromium", "Application", "chrome.exe"))
|
||
out.append(os.path.join(local, "Google", "Chrome", "Application", "chrome.exe"))
|
||
out.append(os.path.join(pf, "Chromium", "Application", "chrome.exe"))
|
||
out.append(os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"))
|
||
out.append(os.path.join(pf86, "Google", "Chrome", "Application", "chrome.exe"))
|
||
# dedupe preserve order
|
||
seen: set[str] = set()
|
||
uniq: list[str] = []
|
||
for p in out:
|
||
key = p.lower()
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
uniq.append(p)
|
||
return tuple(uniq)
|
||
|
||
|
||
_WIN_BROWSERS = _windows_browser_candidates()
|
||
|
||
|
||
def _playwright_chromium_candidates() -> tuple[str, ...]:
|
||
"""Prefer Playwright's Chromium (no snap AppArmor; ReinerBRO/grok-register path)."""
|
||
roots = [
|
||
os.path.expanduser("~/.cache/ms-playwright"),
|
||
os.path.join(os.environ.get("LOCALAPPDATA") or "", "ms-playwright"),
|
||
]
|
||
out: list[str] = []
|
||
for root in roots:
|
||
if not root or not os.path.isdir(root):
|
||
continue
|
||
try:
|
||
for name in sorted(os.listdir(root), reverse=True):
|
||
if not name.lower().startswith("chromium"):
|
||
continue
|
||
# linux: chromium-XXXX/chrome-linux64/chrome
|
||
# win: chromium-XXXX/chrome-win64/chrome.exe
|
||
for rel in (
|
||
("chrome-linux64", "chrome"),
|
||
("chrome-linux", "chrome"),
|
||
("chrome-win64", "chrome.exe"),
|
||
("chrome-win", "chrome.exe"),
|
||
):
|
||
cand = os.path.join(root, name, *rel)
|
||
if os.path.isfile(cand):
|
||
out.append(cand)
|
||
except Exception:
|
||
pass
|
||
return tuple(out)
|
||
|
||
|
||
# Chromium first (turnstilePatch / --load-extension).
|
||
# Playwright Chromium preferred over distro/snap (AppArmor + broken wrapper issues).
|
||
# On Windows, Playwright chromium-* paths contain "chromium" and pass _is_google_chrome_path=False.
|
||
CHROMIUM_CANDIDATES = (
|
||
_playwright_chromium_candidates()
|
||
+ (
|
||
"/usr/bin/chromium",
|
||
"/usr/bin/chromium-browser",
|
||
"/usr/lib/chromium/chromium",
|
||
"/usr/lib/chromium-browser/chromium-browser",
|
||
"/snap/bin/chromium", # keep as /snap/bin/chromium — do NOT resolve to /usr/bin/snap
|
||
)
|
||
+ tuple(p for p in _WIN_BROWSERS if "google" not in p.lower() or "chromium" in p.lower())
|
||
)
|
||
CHROME_CANDIDATES = (
|
||
"/usr/bin/google-chrome-stable",
|
||
"/usr/bin/google-chrome",
|
||
) + tuple(p for p in _WIN_BROWSERS if "google" in p.lower() and "chromium" not in p.lower())
|
||
# Legacy flat list (tests / external callers)
|
||
BROWSER_CANDIDATES = CHROMIUM_CANDIDATES + CHROME_CANDIDATES
|
||
|
||
# Minimal CDP stealth only. Aggressive patches (chrome.runtime delete, permissions
|
||
# monkeypatch, fake plugins/languages, postMessage spam) caused xAI UI to show
|
||
# [permission_denied] HTTP 403 and blocked signup after the full turnstilePatch port.
|
||
STEALTH_INIT_JS = r"""
|
||
(function () {
|
||
if (window.__grokStealthApplied) return;
|
||
window.__grokStealthApplied = true;
|
||
try {
|
||
Object.defineProperty(navigator, "webdriver", {
|
||
get: function () { return false; },
|
||
configurable: true,
|
||
});
|
||
} catch (e) {}
|
||
try {
|
||
// Prefer prototype-level override used by many detectors
|
||
var desc = Object.getOwnPropertyDescriptor(Navigator.prototype, "webdriver");
|
||
if (!desc || desc.configurable) {
|
||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||
get: function () { return false; },
|
||
configurable: true,
|
||
});
|
||
}
|
||
} catch (e) {}
|
||
})();
|
||
"""
|
||
|
||
# Paths that look like snap host / shell wrappers, not a browser binary.
|
||
_INVALID_BROWSER_BASENAMES = frozenset(
|
||
{
|
||
"snap",
|
||
"snap-confine",
|
||
"snapd",
|
||
"env",
|
||
"bash",
|
||
"sh",
|
||
"python",
|
||
"python3",
|
||
"uv",
|
||
}
|
||
)
|
||
|
||
|
||
def _is_google_chrome_path(path: str | None) -> bool:
|
||
if not path:
|
||
return False
|
||
base = os.path.basename(path)
|
||
# /usr/bin/snap is NOT chrome
|
||
if base in _INVALID_BROWSER_BASENAMES:
|
||
return False
|
||
return "chrome" in base and "chromium" not in path.lower()
|
||
|
||
|
||
def _looks_like_browser_path(path: str | None) -> bool:
|
||
"""Reject snap host binary etc. Accept chromium/chrome paths only."""
|
||
if not path:
|
||
return False
|
||
if not (os.path.isfile(path) or os.path.islink(path)):
|
||
return False
|
||
base = os.path.basename(path)
|
||
if base in _INVALID_BROWSER_BASENAMES:
|
||
return False
|
||
low = path.replace("\\", "/").lower()
|
||
# Must look like a browser, not generic "snap"
|
||
if "chromium" in low or "chrome" in low:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _normalize_browser_path(path: str | None) -> str | None:
|
||
"""Return a usable browser executable path.
|
||
|
||
Critical: /snap/bin/chromium often realpath()'s to /usr/bin/snap.
|
||
Using /usr/bin/snap as browser_path makes DrissionPage fail to connect.
|
||
Keep the original /snap/bin/chromium wrapper path instead.
|
||
"""
|
||
if not path:
|
||
return None
|
||
path = str(path).strip()
|
||
if not path:
|
||
return None
|
||
if not (os.path.isfile(path) or os.path.islink(path) or os.path.exists(path)):
|
||
return None
|
||
|
||
# Prefer original path if already valid (esp. /snap/bin/chromium)
|
||
if _looks_like_browser_path(path):
|
||
# If under /snap/bin/, never realpath — that collapses to /usr/bin/snap
|
||
if path.startswith("/snap/bin/"):
|
||
return path
|
||
try:
|
||
real = os.path.realpath(path)
|
||
except Exception:
|
||
real = path
|
||
if _looks_like_browser_path(real):
|
||
return real
|
||
# realpath bad (e.g. snap) but original ok
|
||
return path
|
||
|
||
# realpath might still help for normal symlinks
|
||
try:
|
||
real = os.path.realpath(path)
|
||
except Exception:
|
||
real = ""
|
||
if real and _looks_like_browser_path(real):
|
||
return real
|
||
return None
|
||
|
||
|
||
def _first_existing_browser(candidates) -> str | None:
|
||
for cand in candidates:
|
||
if not cand:
|
||
continue
|
||
norm = _normalize_browser_path(cand)
|
||
if norm:
|
||
return norm
|
||
return None
|
||
|
||
|
||
def resolve_browser_path():
|
||
"""Pick Chromium/Chrome for registration.
|
||
|
||
Priority:
|
||
1. config browser_path (absolute)
|
||
2. env BROWSER_PATH / CHROME_PATH / CHROMIUM_PATH
|
||
3. browser_prefer=chromium|chrome|auto
|
||
auto → Chromium first when turnstilePatch exists (needed for CF)
|
||
4. any remaining candidate
|
||
|
||
Never returns /usr/bin/snap (common snap symlink collapse bug).
|
||
"""
|
||
# Optional override from config.json "browser_path"
|
||
override = str((config.get("browser_path") if isinstance(config, dict) else "") or "").strip()
|
||
if override:
|
||
norm = _normalize_browser_path(override)
|
||
if norm:
|
||
return norm
|
||
print(f" [browser] ignore invalid browser_path={override!r}", flush=True)
|
||
|
||
for env_key in ("BROWSER_PATH", "CHROMIUM_PATH", "CHROME_PATH"):
|
||
env_path = (os.environ.get(env_key) or "").strip()
|
||
if not env_path:
|
||
continue
|
||
norm = _normalize_browser_path(env_path)
|
||
if norm:
|
||
return norm
|
||
print(f" [browser] ignore invalid {env_key}={env_path!r}", flush=True)
|
||
|
||
prefer = str((config.get("browser_prefer") if isinstance(config, dict) else "") or "auto").strip().lower()
|
||
if prefer in ("", "default", "detect"):
|
||
prefer = "auto"
|
||
# env override
|
||
env_pref = (os.environ.get("BROWSER_PREFER") or "").strip().lower()
|
||
if env_pref:
|
||
prefer = env_pref
|
||
|
||
has_ext = os.path.isdir(EXTENSION_PATH)
|
||
if prefer in ("chrome", "google-chrome", "google"):
|
||
order = list(CHROME_CANDIDATES) + list(CHROMIUM_CANDIDATES)
|
||
elif prefer in ("chromium", "chromeium"): # typo tolerant
|
||
order = list(CHROMIUM_CANDIDATES) + list(CHROME_CANDIDATES)
|
||
else:
|
||
# auto: extension needs Chromium; without extension Chrome is fine
|
||
if has_ext:
|
||
order = list(CHROMIUM_CANDIDATES) + list(CHROME_CANDIDATES)
|
||
else:
|
||
order = list(CHROME_CANDIDATES) + list(CHROMIUM_CANDIDATES)
|
||
|
||
return _first_existing_browser(order)
|
||
|
||
|
||
def apply_stealth_patches(page=None, log_callback=None) -> bool:
|
||
"""Minimal anti-automation patch via CDP (Chrome cannot load unpacked extensions).
|
||
|
||
Keep this conservative: over-patching (chrome.runtime / permissions / plugins)
|
||
previously produced xAI [permission_denied] HTTP 403 on signup APIs.
|
||
config.stealth_mode: off | minimal(default) | full
|
||
"""
|
||
mode = str((config.get("stealth_mode") if isinstance(config, dict) else "") or "minimal").strip().lower()
|
||
if mode in ("0", "false", "off", "none", "disabled"):
|
||
return False
|
||
page = page if page is not None else _get_page()
|
||
if page is None:
|
||
return False
|
||
script = STEALTH_INIT_JS
|
||
if mode in ("full", "aggressive", "legacy"):
|
||
# Legacy full patch kept for optional experiment only
|
||
script = STEALTH_INIT_JS # still minimal unless we reintroduce full later
|
||
if getattr(page, "_grok_stealth_applied", False):
|
||
try:
|
||
page.run_js(script)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
ok = False
|
||
try:
|
||
page.add_init_js(script)
|
||
ok = True
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] stealth add_init_js failed: {exc}")
|
||
try:
|
||
page.run_js(script)
|
||
ok = True
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] stealth run_js failed: {exc}")
|
||
if ok:
|
||
try:
|
||
page._grok_stealth_applied = True
|
||
except Exception:
|
||
pass
|
||
if log_callback:
|
||
log_callback(f"[*] stealth patches applied (mode={mode})")
|
||
return ok
|
||
|
||
|
||
def create_browser_options(headless=None):
|
||
"""Build ChromiumOptions for register / TabPool.
|
||
|
||
headless:
|
||
None → resolve from config/env/auto (no DISPLAY → headless)
|
||
True → force headless
|
||
False → prefer headed (still headless if no DISPLAY)
|
||
"""
|
||
options = ChromiumOptions()
|
||
options.auto_port()
|
||
options.set_timeouts(base=1)
|
||
|
||
# Decide headless first so we can choose flag set (headed ≈ reference repo).
|
||
use_hl = should_use_headless(headless, log_hint=True)
|
||
min_browser = str(os.environ.get("GROK_MIN_BROWSER", "") or "").strip().lower() in (
|
||
"1", "true", "yes", "on",
|
||
)
|
||
if min_browser:
|
||
print(" [browser] minimal flags mode (Git-creat7 style)", flush=True)
|
||
options.set_argument("--disable-blink-features=AutomationControlled")
|
||
options.set_argument("--no-first-run")
|
||
if use_hl:
|
||
options.set_argument("--disable-gpu")
|
||
else:
|
||
for flag in CHROMIUM_BASE_FLAGS:
|
||
options.set_argument(flag)
|
||
if use_hl:
|
||
for flag in CHROMIUM_HEADLESS_EXTRA_FLAGS:
|
||
options.set_argument(flag)
|
||
# Do NOT pass --excludeSwitches as a CLI arg (invalid); only real prefs.
|
||
try:
|
||
options.set_pref("credentials_enable_service", False)
|
||
except Exception:
|
||
pass
|
||
# Force normal Chrome UA — default headless UA contains "HeadlessChrome"
|
||
# which Cloudflare often hard-blocks (Attention Required / blocked page).
|
||
ua = (get_user_agent() or "").strip()
|
||
if ua:
|
||
try:
|
||
options.set_user_agent(ua)
|
||
except Exception:
|
||
try:
|
||
options.set_argument(f"--user-agent={ua}")
|
||
except Exception:
|
||
pass
|
||
print(f" [browser] user-agent={ua[:72]}...", flush=True)
|
||
browser_path = resolve_browser_path()
|
||
if browser_path:
|
||
try:
|
||
options.set_browser_path(browser_path)
|
||
print(f" [browser] path={browser_path}", flush=True)
|
||
except Exception:
|
||
pass
|
||
if os.path.exists(EXTENSION_PATH):
|
||
# Google Chrome blocks CLI unpacked extension loading; Chromium still allows it.
|
||
if _is_google_chrome_path(browser_path):
|
||
print(
|
||
" [ext] Chrome: minimal CDP stealth only (extension blocked)\n"
|
||
" 建议安装 Chromium 或 config browser_prefer=chromium / browser_path=/usr/bin/chromium\n"
|
||
" 否则 Turnstile token 常为 0(无头/Xvfb 服务器上尤其明显)",
|
||
flush=True,
|
||
)
|
||
else:
|
||
options.add_extension(EXTENSION_PATH)
|
||
print(f" [ext] turnstilePatch loaded: {EXTENSION_PATH}", flush=True)
|
||
# Apply config.json "proxy" to Chromium. Without this, only HTTP helpers
|
||
# used get_proxies(); the browser itself fell through to system/env proxy.
|
||
proxy = (config.get("proxy") or "").strip()
|
||
if proxy:
|
||
try:
|
||
from urllib.parse import urlparse
|
||
|
||
u = urlparse(proxy if "://" in proxy else f"http://{proxy}")
|
||
host = u.hostname or ""
|
||
if host:
|
||
port = u.port or (443 if (u.scheme or "http") == "https" else 80)
|
||
scheme = u.scheme or "http"
|
||
# Chromium --proxy-server cannot embed user:pass
|
||
options.set_argument(f"--proxy-server={scheme}://{host}:{port}")
|
||
except Exception as e:
|
||
print(f" [proxy] set browser proxy failed: {e}")
|
||
|
||
apply_headless_to_options(options, use_hl)
|
||
if use_hl:
|
||
print(" [browser] headless=True", flush=True)
|
||
else:
|
||
print(
|
||
f" [browser] headed DISPLAY={os.environ.get('DISPLAY', '')!r} "
|
||
f"(flags=base-only, no disable-software-rasterizer)",
|
||
flush=True,
|
||
)
|
||
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 human_sleep(mean_seconds, cancel_callback=None):
|
||
"""高斯分布人类化延迟,sigma=mean*0.3,clamp [mean*0.5, mean*2.0]。
|
||
|
||
PERF_FLAGS sleep_scale / fast 可压缩批量注册等待。
|
||
"""
|
||
scale = float(PERF_FLAGS.get("sleep_scale", 1.0) or 1.0)
|
||
if PERF_FLAGS.get("fast"):
|
||
scale = min(scale, 0.15)
|
||
mean_seconds = max(0.0, float(mean_seconds) * scale)
|
||
if mean_seconds <= 0.01:
|
||
raise_if_cancelled(cancel_callback)
|
||
return
|
||
try:
|
||
delay = random.gauss(mean_seconds, mean_seconds * 0.3)
|
||
except Exception:
|
||
delay = mean_seconds
|
||
delay = max(mean_seconds * 0.5, min(mean_seconds * 2.0, delay))
|
||
sleep_with_cancel(delay, cancel_callback)
|
||
|
||
|
||
def cf_sleep(seconds, cancel_callback=None, *, floor: float = 0.6):
|
||
"""Turnstile/Cloudflare 专用等待:fast 模式仍保留足够墙钟时间。
|
||
|
||
human_sleep 在 --fast 下会被压到 ~0.15x,导致 CF iframe 根本来不及加载/解题。
|
||
这里仅轻度缩放,并强制 floor,避免 token 恒为 0。
|
||
"""
|
||
try:
|
||
sec = float(seconds)
|
||
except Exception:
|
||
sec = 1.0
|
||
scale = float(PERF_FLAGS.get("sleep_scale", 1.0) or 1.0)
|
||
if PERF_FLAGS.get("fast"):
|
||
# 最多压到 0.55x,且不低于 floor
|
||
scale = max(0.55, min(scale, 1.0))
|
||
delay = max(float(floor), sec * scale)
|
||
# 轻微抖动,避免机械节奏
|
||
try:
|
||
delay = max(float(floor), min(delay * 1.35, random.gauss(delay, delay * 0.12)))
|
||
except Exception:
|
||
pass
|
||
sleep_with_cancel(delay, cancel_callback)
|
||
|
||
|
||
|
||
def get_domains(api_key=None):
|
||
headers = {}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
|
||
def create_account(address, password, api_key=None, expires_in=0):
|
||
headers = {"Content-Type": "application/json"}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
data = {"address": address, "password": password, "expiresIn": expires_in}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/accounts", json=data, headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def get_token(address, password):
|
||
data = {"address": address, "password": password}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/token", json=data)
|
||
resp.raise_for_status()
|
||
return resp.json().get("token")
|
||
|
||
|
||
def get_messages(token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
|
||
def get_message_detail(token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def cloudflare_get_domains(api_base, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=False)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_domains", "/domains")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return _pick_list_payload(resp.json())
|
||
|
||
|
||
def cloudflare_create_account(api_base, address, password, api_key=None, expires_in=0):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
payload = {"address": address, "password": password, "expiresIn": expires_in}
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/accounts")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_post(f"{api_base}{path}", json=payload, headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def cloudflare_get_token(api_base, address, password, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_token", "/token")
|
||
resp = http_post(
|
||
f"{api_base}{path}",
|
||
json={"address": address, "password": password},
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
if data.get("token"):
|
||
return data.get("token")
|
||
if isinstance(data.get("data"), dict) and data["data"].get("token"):
|
||
return data["data"].get("token")
|
||
return None
|
||
|
||
|
||
def cloudflare_get_messages(api_base, token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
path = get_cloudflare_path("cloudflare_path_messages", "/messages")
|
||
params = {"limit": 20, "offset": 0}
|
||
params = cloudflare_apply_auth_params(params)
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare messages 返回非JSON: {resp.text[:300]}")
|
||
return _pick_list_payload(data)
|
||
|
||
|
||
def cloudflare_get_message_detail(api_base, token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
candidates = [
|
||
f"{api_base}/api/mail/{message_id}",
|
||
f"{api_base}{get_cloudflare_path('cloudflare_path_messages', '/messages')}/{message_id}",
|
||
]
|
||
last_err = None
|
||
for url in candidates:
|
||
try:
|
||
resp = http_get(
|
||
url,
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict) and isinstance(data.get("data"), dict):
|
||
return data["data"]
|
||
return data
|
||
except Exception as exc:
|
||
last_err = exc
|
||
continue
|
||
raise Exception(f"Cloudflare 获取邮件详情失败: {last_err}")
|
||
|
||
|
||
YYDS_API_BASE = "https://maliapi.215.im/v1"
|
||
|
||
|
||
def get_yyds_api_key():
|
||
return config.get("yyds_api_key", "")
|
||
|
||
|
||
def get_yyds_jwt():
|
||
return config.get("yyds_jwt", "")
|
||
|
||
|
||
def yyds_get_domains(api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(f"{YYDS_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return data.get("data", []) if data.get("success") else []
|
||
|
||
|
||
def yyds_create_account(address=None, domain=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
payload = {}
|
||
if address:
|
||
payload["address"] = address
|
||
if domain:
|
||
payload["domain"] = domain
|
||
elif key or token:
|
||
payload["autoDomainStrategy"] = "prefer_owned"
|
||
resp = http_post(f"{YYDS_API_BASE}/accounts", json=payload, headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {})
|
||
raise Exception(f"YYDS 创建邮箱失败: {data}")
|
||
|
||
|
||
def yyds_get_token(address, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_post(
|
||
f"{YYDS_API_BASE}/token", json={"address": address}, headers=headers
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {}).get("token")
|
||
raise Exception(f"YYDS 获取token失败: {data}")
|
||
|
||
|
||
def yyds_get_messages(address, token=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
temp_token = token or jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if temp_token:
|
||
headers["Authorization"] = f"Bearer {temp_token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(
|
||
f"{YYDS_API_BASE}/messages",
|
||
params={"address": address},
|
||
headers=headers,
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {}).get("messages", [])
|
||
return []
|
||
|
||
|
||
def yyds_get_message_detail(message_id, token=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
temp_token = token or jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if temp_token:
|
||
headers["Authorization"] = f"Bearer {temp_token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(f"{YYDS_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {})
|
||
raise Exception(f"YYDS 获取邮件详情失败: {data}")
|
||
|
||
|
||
def yyds_generate_username(length=10):
|
||
chars = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(chars) for _ in range(length))
|
||
|
||
|
||
def yyds_pick_domain(api_key=None, jwt=None):
|
||
domains = yyds_get_domains(api_key=api_key, jwt=jwt)
|
||
if not domains:
|
||
raise Exception("YYDS 没有返回任何可用域名")
|
||
private = [d for d in domains if d.get("isVerified") and not d.get("isPublic")]
|
||
if private:
|
||
return private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified") and d.get("isPublic")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
verified = [d for d in domains if d.get("isVerified")]
|
||
if verified:
|
||
return verified[0]["domain"]
|
||
raise Exception("YYDS 无已验证域名可用")
|
||
|
||
|
||
def yyds_get_email_and_token(api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
if not token and not key:
|
||
raise Exception("YYDS API Key 或 JWT 未配置")
|
||
domain = yyds_pick_domain(api_key=key, jwt=token)
|
||
username = yyds_generate_username(10)
|
||
result = yyds_create_account(
|
||
address=username, domain=domain, api_key=key, jwt=token
|
||
)
|
||
address = result.get("address") or f"{username}@{domain}"
|
||
temp_token = result.get("token")
|
||
if not temp_token:
|
||
temp_token = yyds_get_token(address, api_key=key, jwt=token)
|
||
if not temp_token:
|
||
raise Exception("获取 YYDS token 失败")
|
||
print(f"[*] 已创建 YYDS 邮箱: {address}")
|
||
return address, temp_token
|
||
|
||
|
||
def yyds_get_oai_code(
|
||
token,
|
||
address,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
jwt=None,
|
||
cancel_callback=None,
|
||
):
|
||
deadline = time.time() + timeout
|
||
seen_ids = set()
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
messages = yyds_get_messages(address, token=token, jwt=jwt)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
for msg in messages:
|
||
msg_id = msg.get("id")
|
||
if not msg_id or msg_id in seen_ids:
|
||
continue
|
||
seen_ids.add(msg_id)
|
||
to_addrs = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
if address.lower() not in to_addrs:
|
||
continue
|
||
try:
|
||
detail = yyds_get_message_detail(msg_id, token=token, jwt=jwt)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 获取邮件详情失败: {exc}")
|
||
continue
|
||
parts = []
|
||
text_body = detail.get("text") or ""
|
||
if text_body:
|
||
parts.append(text_body)
|
||
html_list = detail.get("html") or []
|
||
for h in html_list:
|
||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||
combined = "\n".join(parts)
|
||
subject = detail.get("subject", "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] YYDS 从邮件中提取到验证码: {code}")
|
||
return code
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"YYDS 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
def generate_username(length=10):
|
||
chars = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(chars) for _ in range(length))
|
||
|
||
|
||
def pick_domain(api_key=None):
|
||
domains = get_domains(api_key=api_key)
|
||
if not domains:
|
||
raise Exception("DuckMail 没有返回任何可用域名")
|
||
private = [d for d in domains if d.get("ownerId")]
|
||
verified_private = [d for d in private if d.get("isVerified")]
|
||
if verified_private:
|
||
return verified_private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
raise Exception("DuckMail 无已验证域名可用")
|
||
|
||
|
||
# ──────────────────────── CloudMail (maillab/cloud-mail) ────────────────────────
|
||
# API 前缀: /api/(所有接口均挂载在 /api/ 下)
|
||
# 认证格式: Authorization: <token>(不带 Bearer 前缀)
|
||
# 公开 token 通过 /api/public/genToken 获取(需管理员账号)
|
||
|
||
def get_cloudmail_url():
|
||
return str(os.environ.get("CLOUDMAIL_URL") or config.get("cloudmail_url", "") or "").rstrip("/")
|
||
|
||
|
||
def get_cloudmail_password():
|
||
return os.environ.get("CLOUDMAIL_PASSWORD") or config.get("cloudmail_password", "")
|
||
|
||
|
||
def get_cloudmail_admin_email():
|
||
return str(os.environ.get("CLOUDMAIL_ADMIN_EMAIL") or config.get("cloudmail_admin_email", "") or "").strip()
|
||
|
||
|
||
def cloudmail_login(url, email, password):
|
||
"""POST /api/login -> JWT string"""
|
||
resp = http_post(
|
||
f"{url}/api/login",
|
||
json={"email": email, "password": password},
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict) and data.get("code") == 200:
|
||
token_data = data.get("data", {})
|
||
if isinstance(token_data, dict):
|
||
jwt = token_data.get("token")
|
||
if jwt:
|
||
return jwt
|
||
raise Exception(f"CloudMail 登录失败: {str(data)[:200]}")
|
||
|
||
|
||
def cloudmail_register(url, email, password, turnstile_token=""):
|
||
"""POST /api/register -> 注册用户+账号"""
|
||
payload = {"email": email, "password": password}
|
||
if turnstile_token:
|
||
payload["token"] = turnstile_token
|
||
resp = http_post(
|
||
f"{url}/api/register",
|
||
json=payload,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict) and data.get("code") != 200:
|
||
raise Exception(f"CloudMail 注册失败: {data.get('message', str(data))}")
|
||
return data
|
||
|
||
|
||
def cloudmail_gen_public_token(url, admin_email, admin_password):
|
||
"""POST /api/public/genToken -> 公开 API token (UUID)"""
|
||
resp = http_post(
|
||
f"{url}/api/public/genToken",
|
||
json={"email": admin_email, "password": admin_password},
|
||
headers={"Content-Type": "application/json"},
|
||
proxies={},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict) and data.get("code") == 200:
|
||
token_data = data.get("data", {})
|
||
if isinstance(token_data, dict):
|
||
return token_data.get("token")
|
||
raise Exception(f"CloudMail 获取公开 token 失败: {str(data)[:200]}")
|
||
|
||
|
||
def cloudmail_public_email_list(url, public_token, to_email="", size=20):
|
||
"""POST /api/public/emailList -> 公开邮件查询(需公开 token,Authorization: <token>)"""
|
||
payload = {"size": size}
|
||
if to_email:
|
||
payload["toEmail"] = to_email
|
||
resp = http_post(
|
||
f"{url}/api/public/emailList",
|
||
json=payload,
|
||
headers={
|
||
"Content-Type": "application/json",
|
||
"Authorization": public_token,
|
||
},
|
||
proxies={},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
if data.get("code") == 200:
|
||
return data.get("data", [])
|
||
raise Exception(f"CloudMail 邮件查询失败: {data.get('message', str(data))}")
|
||
return []
|
||
|
||
|
||
def _cloudmail_get_shared_token(force_refresh=False):
|
||
"""获取或刷新共享的公开 token(线程安全单例)"""
|
||
global _cloudmail_public_token
|
||
with _cloudmail_public_token_lock:
|
||
if _cloudmail_public_token and not force_refresh:
|
||
return _cloudmail_public_token
|
||
url = get_cloudmail_url()
|
||
admin_email = get_cloudmail_admin_email()
|
||
admin_password = get_cloudmail_password()
|
||
if not url or not admin_email or not admin_password:
|
||
raise Exception("CloudMail 配置不完整")
|
||
token = cloudmail_gen_public_token(url, admin_email, admin_password)
|
||
if not token:
|
||
raise Exception("CloudMail 公开 token 为空")
|
||
_cloudmail_public_token = token
|
||
return token
|
||
|
||
|
||
def cloudmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=300,
|
||
poll_interval=None,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
# 使用配置的 mail_poll_interval,默认 0.3s
|
||
if poll_interval is None:
|
||
poll_interval = max(0.1, float(config.get("mail_poll_interval", 0.3) or 0.3))
|
||
url = get_cloudmail_url()
|
||
if not url:
|
||
raise Exception("CloudMail URL 未配置")
|
||
# 获取共享公开 token(所有线程共用同一个,避免并发覆盖)
|
||
try:
|
||
public_token = _cloudmail_get_shared_token()
|
||
except Exception as exc:
|
||
raise Exception(f"CloudMail 获取公开 token 失败: {exc}")
|
||
if log_callback:
|
||
log_callback("[Debug] CloudMail 公开 token 获取成功")
|
||
deadline = time.time() + timeout
|
||
seen_attempts = {}
|
||
next_resend_at = time.time() + 60
|
||
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() + 60
|
||
# 统一使用 poll_interval(0.3s 短轮询,无需前加速)
|
||
current_interval = poll_interval
|
||
# 用完整邮箱地址查询(公开 API 的 toEmail 需要完整地址)
|
||
try:
|
||
messages = cloudmail_public_email_list(url, public_token, to_email=email, size=20)
|
||
except Exception as exc:
|
||
err_msg = str(exc)
|
||
if log_callback:
|
||
log_callback(f"[Debug] CloudMail 邮件查询失败: {err_msg}")
|
||
# token 失效时,刷新共享 token(加锁,多线程只刷新一次)
|
||
if "token" in err_msg.lower() or "401" in err_msg:
|
||
try:
|
||
public_token = _cloudmail_get_shared_token(force_refresh=True)
|
||
if log_callback:
|
||
log_callback("[Debug] CloudMail 公开 token 已刷新")
|
||
except Exception:
|
||
pass
|
||
sleep_with_cancel(current_interval, cancel_callback)
|
||
continue
|
||
if log_callback:
|
||
log_callback(f"[Debug] CloudMail 本轮邮件数量: {len(messages)}")
|
||
for msg in messages:
|
||
msg_id = msg.get("emailId") or msg.get("id") or msg.get("messageId")
|
||
if not msg_id:
|
||
continue
|
||
attempt = int(seen_attempts.get(msg_id, 0))
|
||
if attempt >= 5:
|
||
continue
|
||
seen_attempts[msg_id] = attempt + 1
|
||
# 提取邮件内容(公开接口返回 content 字段,为完整 HTML)
|
||
parts = []
|
||
for field in ("content", "text", "textContent", "text_content", "body", "snippet", "intro"):
|
||
value = msg.get(field)
|
||
if isinstance(value, str) and value.strip():
|
||
parts.append(value)
|
||
html_val = msg.get("html") or msg.get("htmlContent") or msg.get("html_content")
|
||
if isinstance(html_val, str):
|
||
parts.append(re.sub(r"<[^>]+>", " ", html_val))
|
||
elif isinstance(html_val, list):
|
||
for h in html_val:
|
||
if isinstance(h, str):
|
||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||
subject = str(msg.get("subject", "") or "")
|
||
combined = "\n".join(parts)
|
||
if log_callback:
|
||
log_callback(f"[Debug] CloudMail 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] CloudMail 从邮件中提取到验证码: {code}")
|
||
return code
|
||
elif log_callback:
|
||
log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}")
|
||
sleep_with_cancel(current_interval, cancel_callback)
|
||
raise Exception(f"CloudMail 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
# ──────────────────────── Hotmail / Outlook OAuth2 IMAP ────────────────────────
|
||
# 导入格式兼容 grok-register:邮箱----密码----ClientID----Token
|
||
# 其中 Token 为 Microsoft OAuth2 refresh_token,验证码通过 outlook.office365.com XOAUTH2 IMAP 拉取。
|
||
|
||
HOTMAIL_TOKEN_ENDPOINTS = [
|
||
# IMAP XOAUTH2 needs an Outlook resource token. Graph Mail.Read tokens can
|
||
# refresh successfully but then fail at IMAP with "authenticated but not connected".
|
||
(
|
||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||
{"scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||
),
|
||
(
|
||
"https://login.live.com/oauth20_token.srf",
|
||
{"scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"},
|
||
),
|
||
# Fallbacks for existing refresh tokens that were issued with legacy/default scopes.
|
||
("https://login.live.com/oauth20_token.srf", {}),
|
||
(
|
||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||
{
|
||
"scope": (
|
||
"offline_access https://graph.microsoft.com/Mail.Read "
|
||
"https://graph.microsoft.com/User.Read"
|
||
)
|
||
},
|
||
),
|
||
]
|
||
|
||
|
||
def _config_bool(value, default=False):
|
||
if value is None:
|
||
return default
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
return bool(value)
|
||
if isinstance(value, str):
|
||
v = value.strip().lower()
|
||
if v in ("1", "true", "yes", "y", "on"):
|
||
return True
|
||
if v in ("0", "false", "no", "n", "off"):
|
||
return False
|
||
return default
|
||
|
||
|
||
def _resolve_project_path(path_value, default_name="mail_credentials.txt"):
|
||
raw = str(path_value or default_name).strip()
|
||
if not raw:
|
||
raw = default_name
|
||
if os.path.isabs(raw):
|
||
return raw
|
||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), raw)
|
||
|
||
|
||
def get_hotmail_accounts_file():
|
||
return _resolve_project_path(config.get("hotmail_accounts_file", "mail_credentials.txt"))
|
||
|
||
|
||
def _hotmail_release_alias(email):
|
||
if not email:
|
||
return
|
||
with _hotmail_selection_lock:
|
||
_hotmail_reserved_aliases.discard(email.strip().lower())
|
||
|
||
|
||
def _hotmail_split_credential_line(line):
|
||
parts = line.rstrip("\n").split("----", 3)
|
||
if len(parts) < 4:
|
||
return None
|
||
email_addr = parts[0].strip()
|
||
password = parts[1].strip()
|
||
client_id = parts[2].strip()
|
||
refresh_token = parts[3].strip()
|
||
if not email_addr or "@" not in email_addr or not client_id or not refresh_token:
|
||
return None
|
||
return {
|
||
"email": email_addr,
|
||
"password": password,
|
||
"client_id": client_id,
|
||
"refresh_token": refresh_token,
|
||
}
|
||
|
||
|
||
def _hotmail_load_accounts(force=False):
|
||
global _hotmail_accounts_cache, _hotmail_accounts_mtime
|
||
path = get_hotmail_accounts_file()
|
||
if not os.path.exists(path):
|
||
raise Exception(f"Hotmail/Outlook 账号文件不存在: {path}")
|
||
mtime = os.path.getmtime(path)
|
||
with _hotmail_accounts_lock:
|
||
if (
|
||
not force
|
||
and _hotmail_accounts_cache is not None
|
||
and _hotmail_accounts_mtime == mtime
|
||
):
|
||
return _hotmail_accounts_cache
|
||
accounts = []
|
||
seen_emails = set()
|
||
with open(path, "r", encoding="utf-8-sig") as f:
|
||
for line_no, raw in enumerate(f, 1):
|
||
line = raw.strip()
|
||
if not line or line.startswith("#") or line.startswith("//"):
|
||
continue
|
||
item = _hotmail_split_credential_line(raw)
|
||
if not item:
|
||
print(f"[Hotmail] 跳过无效账号行 {line_no}: {line[:80]}")
|
||
continue
|
||
email_key = item["email"].strip().lower()
|
||
if email_key in seen_emails:
|
||
print(f"[Hotmail] 跳过重复主邮箱行 {line_no}: {item['email']}")
|
||
continue
|
||
seen_emails.add(email_key)
|
||
item["line_no"] = line_no
|
||
accounts.append(item)
|
||
if not accounts:
|
||
raise Exception(f"Hotmail/Outlook 账号文件无有效记录: {path}")
|
||
_hotmail_accounts_cache = accounts
|
||
_hotmail_accounts_mtime = mtime
|
||
return _hotmail_accounts_cache
|
||
|
||
|
||
def _hotmail_split_email_addr(email_addr):
|
||
raw = str(email_addr or "").strip().lower()
|
||
if "@" not in raw:
|
||
return "", ""
|
||
local, domain = raw.rsplit("@", 1)
|
||
return local, domain
|
||
|
||
|
||
def _hotmail_is_alias_of_main(email_addr, main_email):
|
||
local, domain = _hotmail_split_email_addr(email_addr)
|
||
main_local, main_domain = _hotmail_split_email_addr(main_email)
|
||
if not local or not main_local or domain != main_domain:
|
||
return False
|
||
return local == main_local or local.startswith(main_local + "+")
|
||
|
||
|
||
def _hotmail_iter_tracked_emails():
|
||
"""Yield emails already persisted in success/error ledgers."""
|
||
for fpath in (_EMAILS_USED_FILE, _EMAILS_ERROR_FILE):
|
||
if not os.path.exists(fpath):
|
||
continue
|
||
try:
|
||
with open(fpath, encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
email_addr = line.split("----", 1)[0].strip()
|
||
if email_addr:
|
||
yield email_addr
|
||
except Exception:
|
||
continue
|
||
|
||
|
||
def _hotmail_count_consumed_for_main(main_email):
|
||
"""Count used/failed/reserved aliases belonging to one Hotmail main mailbox."""
|
||
consumed = set()
|
||
for email_addr in _hotmail_iter_tracked_emails():
|
||
if _hotmail_is_alias_of_main(email_addr, main_email):
|
||
consumed.add(email_addr.strip().lower())
|
||
for email_addr in _hotmail_reserved_aliases:
|
||
if _hotmail_is_alias_of_main(email_addr, main_email):
|
||
consumed.add(email_addr.strip().lower())
|
||
return len(consumed)
|
||
|
||
|
||
def _hotmail_alias_available(alias_email):
|
||
alias_key = alias_email.strip().lower()
|
||
return alias_key and alias_key not in _hotmail_reserved_aliases and not is_email_used(alias_email)
|
||
|
||
|
||
def _hotmail_random_suffix(main_local):
|
||
try:
|
||
configured_len = int(config.get("hotmail_alias_random_length", 8) or 8)
|
||
except Exception:
|
||
configured_len = 8
|
||
# Outlook local-part max is 64 chars; keep suffix valid even for long usernames.
|
||
max_len = max(1, 64 - len(str(main_local or "")) - 1)
|
||
length = max(1, min(configured_len, max_len))
|
||
alphabet = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||
|
||
|
||
def _hotmail_make_alias(main_email, alias_index, *, randomize=False):
|
||
if alias_index <= 0:
|
||
return main_email
|
||
local, domain = main_email.split("@", 1)
|
||
if randomize:
|
||
return f"{local}+{_hotmail_random_suffix(local)}@{domain}"
|
||
return f"{local}+{alias_index}@{domain}"
|
||
|
||
|
||
def hotmail_get_email_and_token():
|
||
accounts = _hotmail_load_accounts()
|
||
try:
|
||
max_aliases = int(config.get("hotmail_max_aliases_per_account", 5) or 5)
|
||
except Exception:
|
||
max_aliases = 5
|
||
max_aliases = max(1, max_aliases)
|
||
alias_mode = str(config.get("hotmail_alias_mode", "random") or "random").strip().lower()
|
||
random_mode = alias_mode in ("random", "rand", "随机")
|
||
try:
|
||
random_max_attempts = int(config.get("hotmail_alias_random_max_attempts", 200) or 200)
|
||
except Exception:
|
||
random_max_attempts = 200
|
||
random_max_attempts = max(10, random_max_attempts)
|
||
|
||
with _hotmail_selection_lock:
|
||
for acc in accounts:
|
||
main_email = acc["email"].strip()
|
||
if "@" not in main_email:
|
||
continue
|
||
if _hotmail_count_consumed_for_main(main_email) >= max_aliases:
|
||
continue
|
||
|
||
candidate = None
|
||
# 原邮箱仍优先尝试一次;之后 random 模式使用随机 plus alias。
|
||
if _hotmail_alias_available(main_email):
|
||
candidate = main_email
|
||
elif random_mode:
|
||
for _ in range(random_max_attempts):
|
||
if _hotmail_count_consumed_for_main(main_email) >= max_aliases:
|
||
break
|
||
alias_email = _hotmail_make_alias(main_email, 1, randomize=True)
|
||
if _hotmail_alias_available(alias_email):
|
||
candidate = alias_email
|
||
break
|
||
else:
|
||
for alias_index in range(1, max_aliases):
|
||
alias_email = _hotmail_make_alias(main_email, alias_index)
|
||
if _hotmail_alias_available(alias_email):
|
||
candidate = alias_email
|
||
break
|
||
|
||
if not candidate:
|
||
continue
|
||
|
||
alias_key = candidate.lower()
|
||
_hotmail_reserved_aliases.add(alias_key)
|
||
token_key = "hotmail:" + secrets.token_urlsafe(18)
|
||
_hotmail_token_map[token_key] = {
|
||
"account": acc,
|
||
"email": candidate,
|
||
"created_at": time.time(),
|
||
}
|
||
return candidate, token_key
|
||
raise Exception(
|
||
"Hotmail/Outlook 可用别名已耗尽:请增加 hotmail_max_aliases_per_account、"
|
||
"补充 mail_credentials.txt,或清理 emails_used.txt / emails_error.txt"
|
||
)
|
||
|
||
|
||
def _hotmail_get_refresh_lock(email_addr):
|
||
key = email_addr.strip().lower()
|
||
with _hotmail_refresh_locks_lock:
|
||
lock = _hotmail_refresh_locks.get(key)
|
||
if lock is None:
|
||
lock = threading.Lock()
|
||
_hotmail_refresh_locks[key] = lock
|
||
return lock
|
||
|
||
|
||
def _hotmail_update_refresh_token_file(email_addr, new_refresh_token, log_callback=None):
|
||
path = get_hotmail_accounts_file()
|
||
if not new_refresh_token or not os.path.exists(path):
|
||
return
|
||
with _hotmail_accounts_lock:
|
||
try:
|
||
with open(path, "r", encoding="utf-8-sig") as f:
|
||
lines = f.readlines()
|
||
changed = False
|
||
out_lines = []
|
||
for raw in lines:
|
||
item = _hotmail_split_credential_line(raw)
|
||
if item and item["email"].lower() == email_addr.lower():
|
||
newline = (
|
||
f"{item['email']}----{item['password']}----"
|
||
f"{item['client_id']}----{new_refresh_token}\n"
|
||
)
|
||
out_lines.append(newline)
|
||
changed = True
|
||
else:
|
||
out_lines.append(raw)
|
||
if changed:
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.writelines(out_lines)
|
||
global _hotmail_accounts_mtime
|
||
_hotmail_accounts_mtime = os.path.getmtime(path)
|
||
if log_callback:
|
||
log_callback(f"[*] Hotmail refresh_token 已回写: {email_addr}")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Hotmail refresh_token 回写失败: {exc}")
|
||
|
||
|
||
def hotmail_refresh_access_token(account, log_callback=None):
|
||
email_addr = account["email"]
|
||
lock = _hotmail_get_refresh_lock(email_addr)
|
||
with lock:
|
||
refresh_token = account.get("refresh_token", "")
|
||
last_error = None
|
||
for url, extra in HOTMAIL_TOKEN_ENDPOINTS:
|
||
try:
|
||
data = {
|
||
"client_id": account["client_id"],
|
||
"refresh_token": refresh_token,
|
||
"grant_type": "refresh_token",
|
||
**extra,
|
||
}
|
||
resp = http_post(url, data=data, timeout=30)
|
||
try:
|
||
token_data = resp.json()
|
||
except Exception:
|
||
token_data = {}
|
||
access_token = token_data.get("access_token")
|
||
if access_token:
|
||
new_refresh = token_data.get("refresh_token") or refresh_token
|
||
if new_refresh and new_refresh != refresh_token:
|
||
account["refresh_token"] = new_refresh
|
||
_hotmail_update_refresh_token_file(
|
||
email_addr, new_refresh, log_callback=log_callback
|
||
)
|
||
if log_callback:
|
||
log_callback(f"[*] Hotmail OAuth2 access_token 刷新成功: {email_addr}")
|
||
return access_token
|
||
last_error = token_data.get("error_description") or token_data.get("error") or resp.text[:200]
|
||
except Exception as exc:
|
||
last_error = exc
|
||
continue
|
||
raise Exception(f"Hotmail OAuth2 refresh 失败: {last_error}")
|
||
|
||
|
||
def _hotmail_decode_header(value):
|
||
if not value:
|
||
return ""
|
||
try:
|
||
from email.header import decode_header, make_header
|
||
|
||
return str(make_header(decode_header(value)))
|
||
except Exception:
|
||
return str(value)
|
||
|
||
|
||
def _hotmail_message_body(msg):
|
||
import html as html_lib
|
||
import re as re_lib
|
||
|
||
def decode_part(part):
|
||
payload = part.get_payload(decode=True)
|
||
if payload is None:
|
||
return ""
|
||
charset = part.get_content_charset() or "utf-8"
|
||
return payload.decode(charset, errors="ignore")
|
||
|
||
if msg.is_multipart():
|
||
text_body = ""
|
||
html_body = ""
|
||
for part in msg.walk():
|
||
content_type = part.get_content_type()
|
||
if content_type == "text/plain" and not text_body:
|
||
text_body = decode_part(part)
|
||
elif content_type == "text/html" and not html_body:
|
||
html_body = decode_part(part)
|
||
if text_body:
|
||
return text_body
|
||
return re_lib.sub(r"\s+", " ", re_lib.sub(r"<[^>]+>", " ", html_lib.unescape(html_body))).strip()
|
||
return decode_part(msg)
|
||
|
||
|
||
def _hotmail_get_imap_hosts():
|
||
raw = config.get("hotmail_imap_hosts", "outlook.office365.com,imap-mail.outlook.com")
|
||
if isinstance(raw, (list, tuple)):
|
||
hosts = [str(x).strip() for x in raw if str(x).strip()]
|
||
else:
|
||
hosts = [x.strip() for x in re.split(r"[,,\\s]+", str(raw or "")) if x.strip()]
|
||
out = []
|
||
for host in hosts or ["outlook.office365.com", "imap-mail.outlook.com"]:
|
||
if host not in out:
|
||
out.append(host)
|
||
return out
|
||
|
||
|
||
def _hotmail_imap_get_code(mailbox_email, target_email, access_token, log_callback=None, host=None):
|
||
import email as email_lib
|
||
import imaplib
|
||
from datetime import timezone
|
||
from email.utils import parsedate_to_datetime
|
||
|
||
try:
|
||
recent_seconds = int(config.get("hotmail_recent_seconds", 900) or 900)
|
||
except Exception:
|
||
recent_seconds = 900
|
||
try:
|
||
last_n = int(config.get("hotmail_imap_last_n", 30) or 30)
|
||
except Exception:
|
||
last_n = 30
|
||
require_recipient = _config_bool(
|
||
config.get("hotmail_require_recipient_match", True), default=True
|
||
)
|
||
filter_after_ts = int((time.time() - max(60, recent_seconds)) * 1000)
|
||
target_lower = (target_email or "").strip().lower()
|
||
keywords = ["x.ai", "xai", "grok", "verification", "code", "confirm", "验证码", "确认"]
|
||
|
||
host = host or "outlook.office365.com"
|
||
if log_callback:
|
||
log_callback(f"[Debug] Hotmail/Outlook IMAP 连接: host={host} user={mailbox_email}")
|
||
imap = imaplib.IMAP4_SSL(host, 993, timeout=45)
|
||
auth_string = f"user={mailbox_email}\x01auth=Bearer {access_token}\x01\x01"
|
||
imap.authenticate("XOAUTH2", lambda _: auth_string.encode())
|
||
try:
|
||
imap.select("INBOX")
|
||
status, data = imap.search(None, "ALL")
|
||
if status != "OK" or not data or not data[0]:
|
||
return None
|
||
msg_ids = data[0].split()[-max(1, last_n):]
|
||
for mid in reversed(msg_ids):
|
||
_, msg_data = imap.fetch(mid, "(RFC822)")
|
||
if not msg_data or not msg_data[0] or not isinstance(msg_data[0][1], bytes):
|
||
continue
|
||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||
|
||
date_str = msg.get("Date")
|
||
if date_str:
|
||
try:
|
||
dt = parsedate_to_datetime(date_str)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
if int(dt.timestamp() * 1000) < filter_after_ts:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
subject = _hotmail_decode_header(msg.get("Subject", ""))
|
||
sender = _hotmail_decode_header(msg.get("From", ""))
|
||
recipient_blob = " ".join(
|
||
_hotmail_decode_header(msg.get(h, ""))
|
||
for h in (
|
||
"To",
|
||
"Cc",
|
||
"Delivered-To",
|
||
"X-Original-To",
|
||
"Original-Recipient",
|
||
"Envelope-To",
|
||
)
|
||
).lower()
|
||
recipient_matched = not target_lower or target_lower in recipient_blob
|
||
if require_recipient and not recipient_matched:
|
||
continue
|
||
|
||
body = _hotmail_message_body(msg)
|
||
combined = f"{subject}\n{sender}\n{recipient_blob}\n{body}"
|
||
combined_lower = combined.lower()
|
||
if not any(kw in combined_lower for kw in keywords):
|
||
continue
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] Hotmail/Outlook 从邮件中提取到验证码: {code}")
|
||
if _config_bool(config.get("hotmail_delete_after_code", True), default=True):
|
||
try:
|
||
imap.store(mid, "+FLAGS", r"(\Deleted)")
|
||
try:
|
||
imap.expunge()
|
||
except Exception:
|
||
pass
|
||
if log_callback:
|
||
log_callback(f"[*] Hotmail/Outlook 已删除验证码邮件 mid={mid!r}")
|
||
except Exception as del_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Hotmail/Outlook 删除验证码邮件失败: {del_exc}")
|
||
return code
|
||
return None
|
||
finally:
|
||
try:
|
||
imap.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
imap.logout()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def hotmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
token_info = _hotmail_token_map.get(dev_token)
|
||
if not token_info:
|
||
raise Exception("Hotmail/Outlook dev_token 无效或已过期")
|
||
account = token_info["account"]
|
||
mailbox_email = account["email"]
|
||
try:
|
||
configured_interval = float(config.get("hotmail_poll_interval", 5) or 5)
|
||
except Exception:
|
||
configured_interval = 5.0
|
||
current_interval = max(1.0, configured_interval or float(poll_interval or 3))
|
||
deadline = time.time() + timeout
|
||
access_token = None
|
||
next_resend_at = time.time() + 60
|
||
|
||
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() + 60
|
||
try:
|
||
if not access_token:
|
||
access_token = hotmail_refresh_access_token(account, log_callback=log_callback)
|
||
code = None
|
||
host_errors = []
|
||
for imap_host in _hotmail_get_imap_hosts():
|
||
try:
|
||
code = _hotmail_imap_get_code(
|
||
mailbox_email,
|
||
email,
|
||
access_token,
|
||
log_callback=log_callback,
|
||
host=imap_host,
|
||
)
|
||
# 成功连接但本轮未找到码,不必再换同邮箱另一个 host 重扫。
|
||
break
|
||
except Exception as host_exc:
|
||
host_errors.append(f"{imap_host}: {host_exc}")
|
||
if log_callback:
|
||
log_callback(f"[Debug] Hotmail/Outlook IMAP host 失败: {imap_host}: {host_exc}")
|
||
continue
|
||
if code is None and host_errors and len(host_errors) >= len(_hotmail_get_imap_hosts()):
|
||
raise Exception("; ".join(host_errors))
|
||
if code:
|
||
return code
|
||
if log_callback:
|
||
log_callback(f"[Debug] Hotmail/Outlook 本轮未找到验证码: {email}")
|
||
except Exception as exc:
|
||
# OAuth/IMAP 临时失败时下一轮重新 refresh access_token。
|
||
access_token = None
|
||
if log_callback:
|
||
log_callback(f"[Debug] Hotmail/Outlook 拉取验证码失败: {exc}")
|
||
sleep_with_cancel(current_interval, cancel_callback)
|
||
raise Exception(f"Hotmail/Outlook 在 {timeout}s 内未收到验证码邮件: {email}")
|
||
|
||
|
||
# ──────────────────────── 公共邮箱工具 ────────────────────────
|
||
|
||
def get_email_provider():
|
||
return str(config.get("email_provider", "duckmail") or "duckmail").strip().lower()
|
||
|
||
|
||
# ──────────────────────── Gmail + Cloudflare catch-all ────────────────────────
|
||
|
||
def _gmail_worker_enabled() -> bool:
|
||
if not _config_bool(config.get("gmail_worker_enabled", False), default=False):
|
||
return False
|
||
url = str(config.get("gmail_worker_url", "") or os.getenv("GMAIL_WORKER_URL", "") or "").strip()
|
||
key = str(config.get("gmail_worker_api_key", "") or os.getenv("GMAIL_WORKER_API_KEY", "") or "").strip()
|
||
return bool(url and key)
|
||
|
||
|
||
def _gmail_worker_base_url() -> str:
|
||
return str(config.get("gmail_worker_url", "") or os.getenv("GMAIL_WORKER_URL", "") or "").strip().rstrip("/")
|
||
|
||
|
||
def _gmail_worker_api_key() -> str:
|
||
return str(config.get("gmail_worker_api_key", "") or os.getenv("GMAIL_WORKER_API_KEY", "") or "").strip()
|
||
|
||
|
||
def gmail_worker_fetch_code(
|
||
email: str,
|
||
*,
|
||
timeout: float = 25.0,
|
||
poll_interval: float | None = None,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
consume: bool = True,
|
||
) -> str | None:
|
||
"""Poll Cloudflare Email Worker KV HTTP API for a code.
|
||
|
||
Returns code string or None on timeout / not configured.
|
||
Does NOT raise on 404; raises only on hard misconfiguration after retries optional.
|
||
"""
|
||
base = _gmail_worker_base_url()
|
||
key = _gmail_worker_api_key()
|
||
if not base or not key:
|
||
return None
|
||
if poll_interval is None:
|
||
try:
|
||
poll_interval = float(config.get("gmail_worker_poll_interval", 0.4) or 0.4)
|
||
except Exception:
|
||
poll_interval = 0.4
|
||
poll_interval = max(0.15, min(float(poll_interval), 3.0))
|
||
try:
|
||
timeout = float(timeout)
|
||
except Exception:
|
||
timeout = 25.0
|
||
deadline = time.time() + max(1.0, timeout)
|
||
target = (email or "").strip().lower()
|
||
if not target:
|
||
return None
|
||
t0 = time.time()
|
||
polls = 0
|
||
last_err = ""
|
||
url = f"{base}/code"
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
polls += 1
|
||
try:
|
||
# Prefer no proxy for workers.dev / own CF endpoint (local latency)
|
||
resp = http_get(
|
||
url,
|
||
params={
|
||
"to": target,
|
||
"key": key,
|
||
"consume": "1" if consume else "0",
|
||
},
|
||
headers={"x-api-key": key},
|
||
timeout=8,
|
||
proxies={},
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json() if hasattr(resp, "json") else {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
code = str(data.get("code") or "").strip()
|
||
if code:
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] Gmail Worker 取到验证码: {code} "
|
||
f"(elapsed={time.time() - t0:.1f}s polls={polls} source={data.get('source') or 'worker'})"
|
||
)
|
||
return code
|
||
elif resp.status_code == 404:
|
||
last_err = "not found"
|
||
elif resp.status_code in (401, 403):
|
||
last_err = f"auth {resp.status_code}"
|
||
if log_callback and polls <= 2:
|
||
log_callback(f"[Debug] Gmail Worker 鉴权失败 HTTP {resp.status_code}(检查 gmail_worker_api_key)")
|
||
# auth errors won't recover quickly
|
||
if polls >= 3:
|
||
return None
|
||
else:
|
||
last_err = f"HTTP {resp.status_code}"
|
||
if log_callback and polls <= 3:
|
||
log_callback(f"[Debug] Gmail Worker 响应 {resp.status_code}: {str(getattr(resp, 'text', '') or '')[:120]}")
|
||
except Exception as exc:
|
||
last_err = str(exc)
|
||
if log_callback and polls <= 3:
|
||
log_callback(f"[Debug] Gmail Worker 请求失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] Gmail Worker {timeout:.0f}s 内未取到验证码 ({last_err or 'timeout'}),"
|
||
f"{'将回退 IMAP' if _config_bool(config.get('gmail_imap_fallback', True), default=True) else '结束'}"
|
||
)
|
||
return None
|
||
|
||
|
||
def gmail_get_email_and_token():
|
||
raw = str(config.get("defaultDomains", "") or "")
|
||
domains = [x.strip() for x in re.split(r"[,,\s]+", raw) if x.strip()]
|
||
if not domains:
|
||
raise Exception("Gmail 模式需要在 defaultDomains 中配置域名,如 zhangyunuo.net")
|
||
domain = domains[0]
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
return address, "gmail_catch_all"
|
||
|
||
|
||
def gmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=300,
|
||
poll_interval=None,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
"""Fetch xAI code: Cloudflare Email Worker first (optional), then Gmail IMAP.
|
||
|
||
Hybrid path (recommended):
|
||
xAI → CF Email Routing → Worker(KV) → GET /code (seconds)
|
||
fallback → Gmail IMAP (if Worker miss / disabled)
|
||
|
||
Worker subjects still look like: "ABC-DEF xAI confirmation code".
|
||
IMAP path fetches HEADER only first (subject often has the code).
|
||
"""
|
||
# ── 1) Email Worker (KV HTTP) — fast path ──
|
||
# Lesson from batch10: hits are ~1.5s; misses used to burn 25s worker + ~155s IMAP
|
||
# even when Catch-all only delivers to Worker (IMAP never sees the mail).
|
||
worker_on = _gmail_worker_enabled()
|
||
imap_fallback = _config_bool(config.get("gmail_imap_fallback", True), default=True)
|
||
if worker_on:
|
||
try:
|
||
# Prefer short wait: real hits arrive in 1–3s; long waits only delay retry.
|
||
worker_timeout = float(config.get("gmail_worker_timeout_sec", 12) or 12)
|
||
except Exception:
|
||
worker_timeout = 12.0
|
||
worker_timeout = max(4.0, min(worker_timeout, float(timeout)))
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] Gmail 取码:优先 Email Worker(timeout={worker_timeout:.0f}s)"
|
||
+ (",未命中再短等 IMAP" if imap_fallback else ",未命中即换号")
|
||
)
|
||
code = gmail_worker_fetch_code(
|
||
email,
|
||
timeout=worker_timeout,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
consume=True,
|
||
)
|
||
if code:
|
||
return code
|
||
if not imap_fallback:
|
||
raise Exception(
|
||
f"Gmail Worker 在 {worker_timeout:.0f}s 内未收到验证码(gmail_imap_fallback=false): {email}"
|
||
)
|
||
# Short IMAP fallback only — Catch-all→Worker often never reaches Gmail.
|
||
try:
|
||
imap_fb = float(config.get("gmail_imap_fallback_timeout_sec", 35) or 35)
|
||
except Exception:
|
||
imap_fb = 35.0
|
||
timeout = max(15.0, min(imap_fb, max(0.0, float(timeout) - worker_timeout)))
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] Gmail Worker 未命中,短回退 IMAP(timeout={timeout:.0f}s;"
|
||
f"若邮件只进 Worker 请设 GMAIL_FORWARD_TO 或 gmail_imap_fallback=false)"
|
||
)
|
||
|
||
if poll_interval is None:
|
||
# Gmail catch-all benefits from denser polling; default 1s unless config forces higher.
|
||
poll_interval = float(config.get("mail_poll_interval", 1) or 1)
|
||
poll_interval = max(0.3, min(poll_interval, 5.0))
|
||
imap_user = str(config.get("gmail_imap_user", "") or os.getenv("GMAIL_USER", "") or "").strip()
|
||
imap_password = str(config.get("gmail_imap_password", "") or os.getenv("GMAIL_PASSWORD", "") or "").strip()
|
||
if not imap_user or not imap_password:
|
||
if worker_on:
|
||
raise Exception(
|
||
f"Gmail Worker 未取到验证码,且 IMAP 未配置(gmail_imap_user/password): {email}"
|
||
)
|
||
raise Exception("Gmail IMAP 未配置:需要在 config.json 设置 gmail_imap_user/gmail_imap_password,或环境变量 GMAIL_USER/GMAIL_PASSWORD")
|
||
imap_host = str(config.get("gmail_imap_host", "imap.gmail.com") or "imap.gmail.com").strip()
|
||
imap_port = int(config.get("gmail_imap_port", 993) or 993)
|
||
delete_after = _config_bool(config.get("gmail_delete_after_code", True), default=True)
|
||
deadline = time.time() + timeout
|
||
# Resend: default 45s for pure IMAP; after worker-miss fallback use sooner resend.
|
||
try:
|
||
resend_after = float(config.get("gmail_resend_after_sec", 45) or 45)
|
||
except Exception:
|
||
resend_after = 45.0
|
||
if worker_on:
|
||
# fallback window is short; resend once around 12s into IMAP
|
||
resend_after = min(resend_after, max(8.0, float(config.get("gmail_imap_fallback_resend_sec", 12) or 12)))
|
||
next_resend_at = time.time() + resend_after
|
||
imap = None
|
||
target_lower = email.lower().strip()
|
||
seen_ids: set[bytes] = set()
|
||
poll_n = 0
|
||
t0 = time.time()
|
||
|
||
def _close_imap(conn):
|
||
if conn is None:
|
||
return
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
conn.logout()
|
||
except Exception:
|
||
pass
|
||
|
||
def _ensure_imap():
|
||
nonlocal imap
|
||
if imap is not None:
|
||
try:
|
||
status, _ = imap.noop()
|
||
if status == "OK":
|
||
return imap
|
||
except Exception:
|
||
_close_imap(imap)
|
||
imap = None
|
||
imap = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=45)
|
||
imap.login(imap_user, imap_password)
|
||
imap.select("INBOX")
|
||
return imap
|
||
|
||
def _candidate_ids(conn):
|
||
"""Prefer targeted IMAP SEARCH; fall back to recent ALL."""
|
||
queries = [
|
||
f'(FROM "noreply@x.ai" TO "{target_lower}")',
|
||
f'(FROM "x.ai" TO "{target_lower}")',
|
||
f'(TO "{target_lower}")',
|
||
'(FROM "noreply@x.ai")',
|
||
"UNSEEN",
|
||
"ALL",
|
||
]
|
||
for q in queries:
|
||
try:
|
||
status, data = conn.search(None, q)
|
||
except Exception:
|
||
continue
|
||
if status != "OK" or not data or not data[0]:
|
||
continue
|
||
ids = data[0].split()
|
||
if not ids:
|
||
continue
|
||
# Only scan a small recent window
|
||
if q in ("ALL", "UNSEEN", '(FROM "noreply@x.ai")'):
|
||
ids = ids[-40:]
|
||
return ids, q
|
||
return [], ""
|
||
|
||
def _header_blob(msg) -> tuple[str, str, str]:
|
||
subject = _hotmail_decode_header(msg.get("Subject", ""))
|
||
sender = _hotmail_decode_header(msg.get("From", ""))
|
||
recipient_blob = " ".join(
|
||
_hotmail_decode_header(msg.get(h, ""))
|
||
for h in ("To", "Cc", "Delivered-To", "X-Original-To", "Original-Recipient", "Envelope-To")
|
||
)
|
||
return subject, sender, recipient_blob
|
||
|
||
def _delete_message(conn, mid, code: str) -> None:
|
||
"""Best-effort delete of used verification mail; never blocks returning the code."""
|
||
if not delete_after or conn is None or mid is None:
|
||
return
|
||
try:
|
||
conn.store(mid, "+FLAGS", r"(\Deleted)")
|
||
try:
|
||
conn.expunge()
|
||
except Exception:
|
||
pass
|
||
if log_callback:
|
||
log_callback(f"[*] Gmail IMAP 已删除验证码邮件 mid={mid!r} code={code}")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Gmail IMAP 删除验证码邮件失败: {exc}")
|
||
|
||
try:
|
||
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:
|
||
pass
|
||
next_resend_at = time.time() + 60
|
||
try:
|
||
conn = _ensure_imap()
|
||
msg_ids, qused = _candidate_ids(conn)
|
||
poll_n += 1
|
||
if not msg_ids:
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
filter_ts = int((time.time() - 900) * 1000)
|
||
for mid in reversed(msg_ids):
|
||
if mid in seen_ids:
|
||
continue
|
||
try:
|
||
# HEADER only — subject already contains ABC-DEF code
|
||
_, msg_data = conn.fetch(mid, "(BODY.PEEK[HEADER])")
|
||
except Exception:
|
||
try:
|
||
_, msg_data = conn.fetch(mid, "(RFC822.HEADER)")
|
||
except Exception:
|
||
continue
|
||
if not msg_data or not msg_data[0] or not isinstance(msg_data[0][1], bytes):
|
||
continue
|
||
# Mark seen after successful header fetch to avoid reparse cost
|
||
seen_ids.add(mid)
|
||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||
date_str = msg.get("Date")
|
||
if date_str:
|
||
try:
|
||
dt = parsedate_to_datetime(date_str)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
if int(dt.timestamp() * 1000) < filter_ts:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
subject, sender, recipient_blob = _header_blob(msg)
|
||
recipient_l = recipient_blob.lower()
|
||
if target_lower not in recipient_l:
|
||
# Targeted TO search already matched; still require recipient when possible
|
||
if "TO" not in qused.upper():
|
||
continue
|
||
combined = f"{subject}\n{sender}\n{recipient_blob}".lower()
|
||
if not any(kw in combined for kw in ["x.ai", "xai", "grok", "verification", "code", "confirm", "确认", "验证码"]):
|
||
continue
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
elapsed = time.time() - t0
|
||
log_callback(
|
||
f"[*] Gmail IMAP 从邮件中提取到验证码: {code} "
|
||
f"(elapsed={elapsed:.1f}s polls={poll_n} via={qused or 'ALL'})"
|
||
)
|
||
_delete_message(conn, mid, code)
|
||
return code
|
||
# Rare: code only in body — fetch full message once
|
||
try:
|
||
_, full = conn.fetch(mid, "(BODY.PEEK[])")
|
||
except Exception:
|
||
try:
|
||
_, full = conn.fetch(mid, "(RFC822)")
|
||
except Exception:
|
||
full = None
|
||
if full and full[0] and isinstance(full[0][1], bytes):
|
||
full_msg = email_lib.message_from_bytes(full[0][1])
|
||
body = _hotmail_message_body(full_msg)
|
||
code = extract_verification_code(f"{subject}\n{body}", subject)
|
||
if code:
|
||
if log_callback:
|
||
elapsed = time.time() - t0
|
||
log_callback(
|
||
f"[*] Gmail IMAP 从邮件正文提取到验证码: {code} "
|
||
f"(elapsed={elapsed:.1f}s polls={poll_n})"
|
||
)
|
||
_delete_message(conn, mid, code)
|
||
return code
|
||
except Exception as exc:
|
||
_close_imap(imap)
|
||
imap = None
|
||
if log_callback:
|
||
log_callback(f"[Debug] Gmail IMAP 轮询失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"Gmail IMAP 在 {timeout}s 内未收到验证码邮件: {email}")
|
||
finally:
|
||
_close_imap(imap)
|
||
|
||
|
||
|
||
def get_email_and_token(api_key=None):
|
||
provider = get_email_provider()
|
||
if provider in ("hotmail", "outlook", "outlookmail", "microsoft"):
|
||
return hotmail_get_email_and_token()
|
||
if provider == "yyds":
|
||
return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt())
|
||
if provider == "cloudmail":
|
||
# CloudMail catch-all 模式:直接生成随机邮箱,无需注册
|
||
# Cloudflare Email Routing 会自动将所有该域名的邮件路由到 Worker
|
||
# 支持英文逗号、中文逗号、空格分隔
|
||
raw = str(config.get("defaultDomains", "") or "")
|
||
domains = [x.strip() for x in re.split(r"[,,\s]+", raw) if x.strip()]
|
||
if not domains:
|
||
raise Exception("CloudMail 需要在 defaultDomains 中配置可用域名")
|
||
global _cf_domain_index
|
||
domain = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
# 返回占位 token(实际不用于邮件查询,邮件查询走公开 API)
|
||
return address, "cloudmail_catch_all"
|
||
if provider == "gmail":
|
||
return gmail_get_email_and_token()
|
||
if provider == "cloudflare":
|
||
api_base = get_cloudflare_api_base()
|
||
if not api_base:
|
||
raise Exception("Cloudflare API Base 未配置")
|
||
try:
|
||
# cloudflare_temp_email 专用模式
|
||
return cloudflare_create_temp_address(api_base)
|
||
except Exception as primary_exc:
|
||
# 兜底回退到 Mail.tm 风格
|
||
key = api_key or get_cloudflare_api_key()
|
||
domains = cloudflare_get_domains(api_base, api_key=key)
|
||
if not domains:
|
||
raise Exception(f"Cloudflare 创建邮箱失败: {primary_exc}")
|
||
verified = [d for d in domains if d.get("isVerified")]
|
||
target = verified[0] if verified else domains[0]
|
||
domain = target.get("domain")
|
||
if not domain:
|
||
raise Exception("Cloudflare 域名数据格式错误,缺少 domain 字段")
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
password = secrets.token_urlsafe(12)
|
||
cloudflare_create_account(
|
||
api_base, address, password, api_key=key, expires_in=0
|
||
)
|
||
token = cloudflare_get_token(api_base, address, password, api_key=key)
|
||
if not token:
|
||
raise Exception("获取 Cloudflare 邮箱 token 失败")
|
||
return address, token
|
||
key = api_key or get_duckmail_api_key()
|
||
domain = pick_domain(api_key=key)
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
password = secrets.token_urlsafe(12)
|
||
create_account(address, password, api_key=key, expires_in=0)
|
||
token = get_token(address, password)
|
||
if not token:
|
||
raise Exception("获取 DuckMail token 失败")
|
||
return address, token
|
||
|
||
|
||
def get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
provider = get_email_provider()
|
||
if provider in ("hotmail", "outlook", "outlookmail", "microsoft"):
|
||
return hotmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
if provider == "yyds":
|
||
return yyds_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
jwt=get_yyds_jwt(),
|
||
cancel_callback=cancel_callback,
|
||
)
|
||
if provider == "cloudmail":
|
||
return cloudmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
if provider == "gmail":
|
||
return gmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
if provider == "cloudflare":
|
||
return cloudflare_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
return duckmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
)
|
||
|
||
|
||
def extract_verification_code(text, subject=""):
|
||
if subject:
|
||
match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
patterns = [
|
||
r"verification\s+code[:\s]+(\d{4,8})",
|
||
r"your\s+code[:\s]+(\d{4,8})",
|
||
r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})",
|
||
]
|
||
for pattern in patterns:
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
return None
|
||
|
||
|
||
def 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 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: {res.text[:200]}"
|
||
)
|
||
return res.status_code == 200
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[set_birth_date] 寮傚父: {e}")
|
||
return False
|
||
|
||
|
||
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}")
|
||
return res.status_code == 200
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[set_tos_accepted] 寮傚父: {e}")
|
||
return False
|
||
|
||
|
||
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}")
|
||
return res.status_code == 200
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[update_nsfw] 寮傚父: {e}")
|
||
return False
|
||
|
||
|
||
def enable_nsfw_for_token(token, cf_clearance="", log_callback=None):
|
||
proxies = get_proxies()
|
||
user_agent = get_user_agent()
|
||
try:
|
||
with requests.Session(impersonate="chrome120", proxies=proxies) as session:
|
||
session.headers.update(
|
||
{
|
||
"user-agent": user_agent,
|
||
"cookie": f"sso={token}; sso-rw={token}; cf_clearance={cf_clearance}",
|
||
}
|
||
)
|
||
if not set_tos_accepted(session, log_callback):
|
||
return False, "set_tos_accepted 澶辫触!"
|
||
if not set_birth_date(session, log_callback):
|
||
return False, "set_birth_date 澶辫触!"
|
||
if not update_nsfw_settings(session, log_callback):
|
||
return False, "update_nsfw_settings 澶辫触!"
|
||
return True, "成功开启NSFW"
|
||
except Exception as e:
|
||
return False, f"寮傚父: {str(e)}"
|
||
|
||
|
||
SIGNUP_URL = "https://accounts.x.ai/sign-up?redirect=grok-com"
|
||
|
||
_thread_ctx = threading.local()
|
||
|
||
from tab_pool import TabPool
|
||
|
||
|
||
def _get_browser():
|
||
return TabPool.get_browser()
|
||
|
||
|
||
def _set_browser(value):
|
||
pass # TabPool 管理 browser,外部 setter 为 no-op
|
||
|
||
|
||
def _get_page():
|
||
if TabPool.get_browser() is None:
|
||
return None
|
||
return TabPool.get_tab()
|
||
|
||
|
||
def _set_page(value):
|
||
pass # TabPool 管理 tab,外部 setter 为 no-op
|
||
|
||
|
||
def start_browser(log_callback=None):
|
||
last_exc = None
|
||
for attempt in range(1, 5):
|
||
try:
|
||
TabPool.init(create_browser_options, log_callback=log_callback)
|
||
page = TabPool.get_tab()
|
||
apply_stealth_patches(page, log_callback=log_callback)
|
||
if log_callback and attempt > 1:
|
||
log_callback(f"[*] 浏览器第 {attempt} 次启动成功")
|
||
return TabPool.get_browser(), page
|
||
except Exception as exc:
|
||
last_exc = exc
|
||
if log_callback:
|
||
log_callback(f"[Debug] 浏览器启动失败(第{attempt}/4次): {exc}")
|
||
# 每线程独立浏览器,shutdown 只影响当前线程
|
||
try:
|
||
TabPool.release_tab()
|
||
except Exception:
|
||
pass
|
||
human_sleep(min(1.5 * attempt, 4))
|
||
raise Exception(f"浏览器启动失败,已重试4次: {last_exc}")
|
||
|
||
|
||
def stop_browser():
|
||
"""Quit current-thread Chromium (full process exit + del_data)."""
|
||
TabPool.release_tab()
|
||
|
||
|
||
def prepare_browser_for_next_account(log_callback=None, force_recycle: bool = False):
|
||
"""Between accounts: clear session (reuse) or full recycle.
|
||
|
||
Returns (browser, page).
|
||
"""
|
||
reuse = bool(PERF_FLAGS.get("browser_reuse", True)) and not force_recycle
|
||
every = int(PERF_FLAGS.get("browser_recycle_every", 25) or 25)
|
||
served = TabPool.served_count()
|
||
if reuse and TabPool.get_browser() is not None and (every <= 0 or served < every):
|
||
if TabPool.clear_session(log_callback=log_callback):
|
||
TabPool.mark_served()
|
||
page = _get_page()
|
||
# Re-apply stealth after session wipe / about:blank
|
||
apply_stealth_patches(page, log_callback=log_callback)
|
||
return TabPool.get_browser(), page
|
||
# full recycle
|
||
if log_callback:
|
||
log_callback(f"[*] 浏览器完整回收(reuse={reuse}, served={served}, every={every})")
|
||
TabPool.release_tab()
|
||
return start_browser(log_callback=log_callback)
|
||
|
||
|
||
def shutdown_browser():
|
||
"""Quit all tracked Chromium instances."""
|
||
TabPool.shutdown()
|
||
|
||
|
||
def restart_browser(log_callback=None):
|
||
TabPool.release_tab()
|
||
return start_browser(log_callback=log_callback)
|
||
|
||
|
||
def refresh_active_page():
|
||
if TabPool.get_browser() is None:
|
||
restart_browser()
|
||
try:
|
||
browser = TabPool.get_browser()
|
||
tabs = browser.tab_ids
|
||
if tabs:
|
||
page = browser.get_tab(tabs[-1])
|
||
else:
|
||
page = browser.new_tab()
|
||
page.refresh()
|
||
TabPool.sync_tab()
|
||
except Exception:
|
||
restart_browser()
|
||
return _get_page()
|
||
|
||
|
||
def click_email_signup_button(timeout=10, log_callback=None, cancel_callback=None):
|
||
page = _get_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"""
|
||
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
||
const target = candidates.find((node) => {
|
||
const text = (node.innerText || node.textContent || '').replace(/\s+/g, '');
|
||
const lower = text.toLowerCase();
|
||
return (
|
||
text.includes('使用邮箱注册') ||
|
||
lower.includes('signupwithemail') ||
|
||
lower.includes('continuewithemail') ||
|
||
lower.includes('email')
|
||
);
|
||
});
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
target.click();
|
||
return true;
|
||
""")
|
||
|
||
if clicked:
|
||
if log_callback:
|
||
log_callback("[*] 已点击「使用邮箱注册」按钮")
|
||
human_sleep(2, cancel_callback)
|
||
return True
|
||
|
||
if log_callback:
|
||
current_url = page.url if page else "none"
|
||
log_callback(f"[Debug] 当前URL: {current_url}")
|
||
|
||
human_sleep(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 dismiss_cookie_banner(page=None, log_callback=None) -> bool:
|
||
"""Dismiss xAI/OneTrust cookie consent that blocks Sign up / Turnstile.
|
||
|
||
Historical success path always did a real Accept All Cookies click.
|
||
force-hide alone often leaves preference-center DOM and CF stays token=0.
|
||
Prefer DrissionPage native element clicks; JS/force-hide only as fallback.
|
||
|
||
IMPORTANT: once dismissed on a page, do NOT keep re-clicking OneTrust buttons
|
||
during Turnstile Verifying — that restarts CF and leaves token=0 forever.
|
||
"""
|
||
page = page if page is not None else _get_page()
|
||
if page is None:
|
||
return False
|
||
|
||
# Sticky per-page: avoid click storms while Turnstile is Verifying
|
||
if getattr(page, "_grok_cookie_dismissed", False):
|
||
# Still force-hide if preference center somehow re-opened
|
||
try:
|
||
still = page.run_js(
|
||
r"""
|
||
try {
|
||
const ban = document.querySelector('#onetrust-banner-sdk, #onetrust-pc-sdk');
|
||
if (!ban) return false;
|
||
const s = getComputedStyle(ban);
|
||
const r = ban.getBoundingClientRect();
|
||
return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 40 && r.height > 40;
|
||
} catch(e) { return false; }
|
||
"""
|
||
)
|
||
if not still:
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
# Ordered: accept/allow first (historical winner), then reject/confirm/close.
|
||
native_texts = [
|
||
"Accept All Cookies",
|
||
"Accept All",
|
||
"Allow All",
|
||
"Allow all",
|
||
"接受所有 Cookie",
|
||
"接受所有",
|
||
"接受全部",
|
||
"全部接受",
|
||
"Reject All",
|
||
"Reject all",
|
||
"全部拒绝",
|
||
"拒绝全部",
|
||
"Confirm My Choices",
|
||
"Confirm my choices",
|
||
"确认我的选择",
|
||
]
|
||
# Prefer real Accept/Allow All only. Do NOT click save-preference first —
|
||
# that only saves partial choices and CF Verifying often never completes.
|
||
css_ids = [
|
||
"#onetrust-accept-btn-handler",
|
||
"#accept-recommended-btn-handler",
|
||
"#onetrust-reject-all-handler",
|
||
".ot-pc-refuse-all-handler",
|
||
"#close-pc-btn-handler",
|
||
".onetrust-close-btn-handler",
|
||
# save-preference last resort only
|
||
".save-preference-btn-handler",
|
||
]
|
||
|
||
def _cookie_still_present() -> bool:
|
||
try:
|
||
return bool(
|
||
page.run_js(
|
||
r"""
|
||
function vis(n){
|
||
if(!n) return false;
|
||
try{
|
||
const s=getComputedStyle(n);
|
||
if(s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0) return false;
|
||
const r=n.getBoundingClientRect();
|
||
return r.width>0 && r.height>0;
|
||
}catch(e){return false;}
|
||
}
|
||
if (['#onetrust-banner-sdk','#onetrust-consent-sdk','#onetrust-pc-sdk','.onetrust-pc-dark-filter']
|
||
.some(sel => vis(document.querySelector(sel)))) return true;
|
||
return Array.from(document.querySelectorAll('button,a,[role="button"]')).some(n=>{
|
||
if(!vis(n)) return false;
|
||
const t=((n.innerText||n.textContent||'')+'').toLowerCase();
|
||
return /accept all cookies|reject all|cookie settings|allow all|confirm my choices|接受所有|全部拒绝/.test(t);
|
||
});
|
||
"""
|
||
)
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
def _native_click_once() -> str:
|
||
# 1) OneTrust ids via CSS
|
||
for sel in css_ids:
|
||
try:
|
||
el = page.ele(f"css:{sel}", timeout=0.25)
|
||
except Exception:
|
||
el = None
|
||
if el is None:
|
||
continue
|
||
try:
|
||
el.click()
|
||
return f"clicked:{sel}"
|
||
except Exception:
|
||
try:
|
||
el.click(by_js=True)
|
||
return f"clicked-js:{sel}"
|
||
except Exception:
|
||
pass
|
||
# 2) Enumerate ALL buttons/links in Python (more reliable than text: locator)
|
||
prefer_keys = [
|
||
"accept all cookies", "accept all", "allow all", "allow all cookies",
|
||
"接受所有 cookie", "接受所有", "接受全部", "全部接受", "允许全部",
|
||
"reject all", "reject all cookies", "全部拒绝", "拒绝全部",
|
||
# confirm/save last — partial consent often leaves CF Verifying forever
|
||
"confirm my choices", "确认我的选择", "save preferences", "save my preferences",
|
||
]
|
||
try:
|
||
candidates = page.eles("tag:button", timeout=0.4) or []
|
||
except Exception:
|
||
candidates = []
|
||
try:
|
||
more = page.eles("css:a,[role='button']", timeout=0.2) or []
|
||
candidates = list(candidates) + list(more)
|
||
except Exception:
|
||
pass
|
||
best = None
|
||
best_label = ""
|
||
best_rank = 10**9
|
||
for el in candidates:
|
||
try:
|
||
label = (
|
||
(getattr(el, "text", None) or el.attr("aria-label") or el.attr("title") or "")
|
||
.replace("\n", " ")
|
||
.strip()
|
||
.lower()
|
||
)
|
||
except Exception:
|
||
continue
|
||
if not label or "cookie settings" in label or "cookies settings" in label:
|
||
continue
|
||
if label in ("filter icon", "clear", "apply", "back button", "cancel", "search…", "search..."):
|
||
continue
|
||
for rank, key in enumerate(prefer_keys):
|
||
if key == label or key in label:
|
||
if rank < best_rank:
|
||
best = el
|
||
best_label = label
|
||
best_rank = rank
|
||
break
|
||
if best is not None:
|
||
for mode in ("click", "js", "actions"):
|
||
try:
|
||
if mode == "click":
|
||
best.click()
|
||
elif mode == "js":
|
||
best.click(by_js=True)
|
||
else:
|
||
page.actions.click(best)
|
||
return f"clicked:{best_label[:40]}"
|
||
except Exception:
|
||
continue
|
||
# 3) Visible text match (DrissionPage text locator fallback)
|
||
for text in native_texts:
|
||
el = None
|
||
for loc in (f"text:{text}", f"tag:button@@text():{text}", f"@text():{text}"):
|
||
try:
|
||
el = page.ele(loc, timeout=0.15)
|
||
except Exception:
|
||
el = None
|
||
if el is not None:
|
||
break
|
||
if el is None:
|
||
continue
|
||
try:
|
||
el.click()
|
||
return f"clicked:{text}"
|
||
except Exception:
|
||
try:
|
||
el.click(by_js=True)
|
||
return f"clicked-js:{text}"
|
||
except Exception:
|
||
try:
|
||
page.actions.click(el)
|
||
return f"clicked-actions:{text}"
|
||
except Exception:
|
||
pass
|
||
return "none"
|
||
|
||
def _js_click_once() -> str:
|
||
try:
|
||
return str(
|
||
page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
try {
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
} catch (e) { return false; }
|
||
}
|
||
function labelOf(n) {
|
||
return ((n.innerText || n.textContent || n.getAttribute('aria-label') || n.getAttribute('title') || '') + '')
|
||
.replace(/\s+/g, ' ').trim().toLowerCase();
|
||
}
|
||
const prefer = [
|
||
'accept all cookies', 'accept all', 'allow all', 'allow all cookies',
|
||
'confirm my choices', 'confirm my choice', 'save preferences',
|
||
'reject all', 'reject all cookies',
|
||
'接受所有 cookie', '接受所有', '接受全部', '全部接受', '允许全部', '确认我的选择',
|
||
'拒绝全部', '全部拒绝'
|
||
];
|
||
for (const sel of [
|
||
'#onetrust-accept-btn-handler','#accept-recommended-btn-handler',
|
||
'#onetrust-reject-all-handler','.ot-pc-refuse-all-handler',
|
||
'.save-preference-btn-handler','button.save-preference-btn-handler',
|
||
'.onetrust-close-btn-handler','#close-pc-btn-handler'
|
||
]) {
|
||
const el = document.querySelector(sel);
|
||
if (el && isVisible(el) && !el.disabled) {
|
||
try { el.click(); return 'clicked:' + sel; } catch (e) {}
|
||
}
|
||
}
|
||
const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
||
for (const t of prefer) {
|
||
const target = nodes.find((n) => {
|
||
if (!isVisible(n) || n.disabled) return false;
|
||
const label = labelOf(n);
|
||
if (!label || label.includes('cookie settings') || label.includes('cookies settings')) return false;
|
||
if (['filter icon','clear','apply','back button','cancel'].includes(label)) return false;
|
||
return label === t || label.includes(t);
|
||
});
|
||
if (target) {
|
||
try { target.click(); return 'clicked:' + ((target.innerText||t)+'').trim().slice(0,40); } catch(e) {}
|
||
}
|
||
}
|
||
return 'none';
|
||
"""
|
||
)
|
||
or "none"
|
||
)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] cookie js dismiss err: {exc}")
|
||
return "err"
|
||
|
||
def _force_hide() -> str:
|
||
try:
|
||
page.run_js(
|
||
r"""
|
||
try {
|
||
document.querySelectorAll(
|
||
'#onetrust-banner-sdk, #onetrust-consent-sdk, .onetrust-pc-dark-filter, #onetrust-pc-sdk, #ot-sdk-btn-floating, [class*="cookie-banner"], [id*="cookie-banner"]'
|
||
).forEach((el) => {
|
||
try {
|
||
el.style.setProperty('display', 'none', 'important');
|
||
el.style.setProperty('visibility', 'hidden', 'important');
|
||
el.style.setProperty('pointer-events', 'none', 'important');
|
||
el.setAttribute('aria-hidden', 'true');
|
||
} catch (e) {}
|
||
});
|
||
document.documentElement && document.documentElement.classList.remove('ot-fade-in', 'onetrust-no-scroll');
|
||
if (document.body) {
|
||
document.body.classList.remove('ot-fade-in', 'onetrust-no-scroll');
|
||
document.body.style.overflow = '';
|
||
}
|
||
} catch (e) {}
|
||
"""
|
||
)
|
||
return "force-hide"
|
||
except Exception:
|
||
return "force-hide-failed"
|
||
|
||
def _seed_optanon_cookies() -> None:
|
||
"""Best-effort: mark OneTrust already accepted so banner/PC does not block CF."""
|
||
try:
|
||
page.run_js(
|
||
r"""
|
||
try {
|
||
const domains = [location.hostname, '.x.ai', '.accounts.x.ai'];
|
||
const now = new Date();
|
||
const exp = new Date(now.getTime() + 365*24*3600*1000).toUTCString();
|
||
const pairs = [
|
||
'OptanonAlertBoxClosed=' + now.toISOString(),
|
||
// groups: necessary + performance/functional/targeting commonly "1"
|
||
'OptanonConsent=isGpcEnabled=0&datestamp=' + encodeURIComponent(now.toString()) +
|
||
'&version=202401.1.0&isIABGlobal=false&hosts=&consentId=auto&interactionCount=1&landingPath=NotLandingPage&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A1&AwaitingReconsent=false'
|
||
];
|
||
for (const d of domains) {
|
||
for (const p of pairs) {
|
||
try { document.cookie = p + '; path=/; domain=' + d + '; expires=' + exp + '; SameSite=Lax'; } catch (e) {}
|
||
try { document.cookie = p + '; path=/; expires=' + exp + '; SameSite=Lax'; } catch (e) {}
|
||
}
|
||
}
|
||
try {
|
||
if (window.OneTrust && typeof OneTrust.AllowAll === 'function') OneTrust.AllowAll();
|
||
} catch (e) {}
|
||
try {
|
||
if (window.OneTrust && typeof OneTrust.Close === 'function') OneTrust.Close();
|
||
} catch (e) {}
|
||
} catch (e) {}
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
present = _cookie_still_present()
|
||
# Seed consent cookies first — often enough for OneTrust to stop re-opening PC.
|
||
_seed_optanon_cookies()
|
||
human_sleep(0.15)
|
||
|
||
# Even if presence probe is flaky, still try a few click rounds when called
|
||
# from profile/signup — historical success always got "Accept All Cookies".
|
||
any_ok = False
|
||
clicked_label = ""
|
||
max_rounds = 5 if present else 3
|
||
for round_i in range(max_rounds):
|
||
if round_i > 0 and not _cookie_still_present() and any_ok:
|
||
break
|
||
result = _native_click_once()
|
||
if result == "none":
|
||
result = _js_click_once()
|
||
# After first real click attempt, also force-hide residual PC/banner
|
||
if result.startswith("clicked") or (result in ("none", "err") and round_i >= 1):
|
||
if result.startswith("clicked"):
|
||
any_ok = True
|
||
clicked_label = result.split(":", 1)[-1]
|
||
if log_callback and round_i == 0:
|
||
log_callback(f"[*] 已关闭 Cookie 弹窗: {clicked_label}")
|
||
_seed_optanon_cookies()
|
||
_force_hide()
|
||
any_ok = True
|
||
human_sleep(0.3)
|
||
# Stop after one successful accept + hide — do not keep re-clicking
|
||
if clicked_label or not _cookie_still_present():
|
||
break
|
||
else:
|
||
human_sleep(0.12)
|
||
# Final hard hide — remove entire OneTrust tree so it cannot re-open mid-CF
|
||
try:
|
||
page.run_js(
|
||
r"""
|
||
try {
|
||
['#onetrust-consent-sdk','#onetrust-banner-sdk','#onetrust-pc-sdk','.onetrust-pc-dark-filter','#ot-sdk-btn-floating']
|
||
.forEach((sel) => {
|
||
document.querySelectorAll(sel).forEach((el) => {
|
||
try { el.remove(); } catch (e) {
|
||
try {
|
||
el.style.setProperty('display','none','important');
|
||
el.style.setProperty('visibility','hidden','important');
|
||
el.style.setProperty('pointer-events','none','important');
|
||
} catch (e2) {}
|
||
}
|
||
});
|
||
});
|
||
if (document.body) {
|
||
document.body.classList.remove('ot-fade-in','onetrust-no-scroll');
|
||
document.body.style.overflow = '';
|
||
}
|
||
} catch (e) {}
|
||
"""
|
||
)
|
||
any_ok = True
|
||
except Exception:
|
||
if _cookie_still_present():
|
||
_force_hide()
|
||
any_ok = True
|
||
try:
|
||
page._grok_cookie_dismissed = True
|
||
except Exception:
|
||
pass
|
||
return any_ok
|
||
|
||
|
||
def open_signup_page(log_callback=None, cancel_callback=None):
|
||
browser = _get_browser()
|
||
page = _get_page()
|
||
raise_if_cancelled(cancel_callback)
|
||
if browser is None:
|
||
browser, page = start_browser(log_callback=log_callback)
|
||
if log_callback:
|
||
log_callback("[*] 浏览器已启动")
|
||
# Ensure CDP stealth is on this tab before navigating to CF-protected pages
|
||
apply_stealth_patches(_get_page(), log_callback=log_callback)
|
||
try:
|
||
page = _get_page()
|
||
page.get(SIGNUP_URL)
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 打开URL异常: {e}")
|
||
try:
|
||
TabPool.release_tab()
|
||
browser, page = start_browser(log_callback=log_callback)
|
||
page.get(SIGNUP_URL)
|
||
except Exception as e2:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 创建新标签页异常: {e2}")
|
||
restart_browser(log_callback=log_callback)
|
||
page = _get_page()
|
||
page.get(SIGNUP_URL)
|
||
page = _get_page()
|
||
apply_stealth_patches(page, log_callback=None)
|
||
page.wait.doc_loaded()
|
||
# Cookie consent often blocks Sign up / email submit (see network 403 on /mp/track is unrelated)
|
||
dismiss_cookie_banner(page, log_callback=log_callback)
|
||
dump_state(page, "signup-loaded")
|
||
take_screenshot(page, "signup")
|
||
human_sleep(1.2, cancel_callback)
|
||
dismiss_cookie_banner(page, log_callback=None)
|
||
if log_callback:
|
||
log_callback(f"[*] 当前URL: {page.url}")
|
||
click_email_signup_button(
|
||
log_callback=log_callback, cancel_callback=cancel_callback
|
||
)
|
||
dismiss_cookie_banner(_get_page(), log_callback=log_callback)
|
||
dump_state(page, "after-email-signup-click")
|
||
|
||
|
||
def has_profile_form(log_callback=None):
|
||
page = refresh_active_page()
|
||
try:
|
||
return bool(
|
||
page.run_js(
|
||
"""
|
||
const givenInput = document.querySelector('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"]');
|
||
const familyInput = document.querySelector('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"]');
|
||
const passwordInput = document.querySelector('input[data-testid="password"], input[name="password"], input[type="password"]');
|
||
return !!(givenInput && familyInput && passwordInput);
|
||
"""
|
||
)
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def fill_email_and_submit(timeout=15, log_callback=None, cancel_callback=None):
|
||
page = _get_page()
|
||
raise_if_cancelled(cancel_callback)
|
||
check_timeout(time.time())
|
||
# Cookie modal blocks the real Sign up button click
|
||
dismiss_cookie_banner(page, log_callback=log_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
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
dismiss_cookie_banner(page, log_callback=None)
|
||
filled = page.run_js(
|
||
"""
|
||
const email = arguments[0];
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
const input = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"]')).find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null;
|
||
if (!input) return 'not-ready';
|
||
input.focus(); input.click();
|
||
// 清空并设置值
|
||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||
const tracker = input._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (valueSetter) valueSetter.call(input, email); else input.value = email;
|
||
// 完整事件序列,确保 React 受控组件同步
|
||
input.dispatchEvent(new Event('focus', { bubbles: true }));
|
||
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 }));
|
||
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||
// 验证:值已写入即可(不依赖 checkValidity,部分站点自定义校验会导致误判)
|
||
const current = (input.value || '').trim();
|
||
if (current === email) return 'filled';
|
||
// 兜底:尝试逐字符输入
|
||
input.value = '';
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
for (const ch of email) {
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true }));
|
||
input.value += ch;
|
||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }));
|
||
input.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true }));
|
||
}
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
if ((input.value || '').trim() === email) return 'filled';
|
||
return input.value;
|
||
""",
|
||
email,
|
||
)
|
||
if filled == "not-ready":
|
||
human_sleep(0.5, cancel_callback)
|
||
continue
|
||
if filled != "filled":
|
||
if log_callback:
|
||
log_callback(f"[Debug] 邮箱输入框已出现,但写入失败: {filled}")
|
||
human_sleep(0.5, cancel_callback)
|
||
continue
|
||
human_sleep(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;
|
||
}
|
||
const input = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"]')).find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null;
|
||
// Prefer presence of non-empty value over checkValidity(): custom validation can false-negative.
|
||
if (!input || !(input.value || '').trim()) return 'no-input-value';
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button')).filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||
const submitButton = buttons.find((node) => {
|
||
// Keep both spaced and unspaced forms: we strip whitespace for matching.
|
||
const raw = (node.innerText || node.textContent || '').trim();
|
||
const compact = raw.replace(/\s+/g, '').toLowerCase();
|
||
return (
|
||
raw.includes('注册') ||
|
||
compact === 'signup' ||
|
||
compact.includes('signup') ||
|
||
compact.includes('continue') ||
|
||
compact.includes('next') ||
|
||
compact.includes('createaccount') ||
|
||
compact.includes('submit')
|
||
);
|
||
});
|
||
if (!submitButton) {
|
||
return 'no-submit:' + buttons.map((b) => (b.innerText || '').trim()).filter(Boolean).slice(0, 8).join('|');
|
||
}
|
||
if (submitButton.disabled) return 'submit-disabled';
|
||
submitButton.click();
|
||
return 'clicked';
|
||
"""
|
||
)
|
||
if clicked in (True, "clicked", "true", 1):
|
||
if log_callback:
|
||
log_callback(f"[*] 已填写邮箱并点击注册: {email}")
|
||
# Wait for transition to OTP / next step (email form should leave or code inputs appear)
|
||
try:
|
||
confirm_sec = max(8, int(config.get("email_submit_confirm_timeout", 30) or 30))
|
||
except Exception:
|
||
confirm_sec = 30
|
||
transit_deadline = time.time() + confirm_sec
|
||
last_state = "waiting"
|
||
reclicked = False
|
||
cf_tried = False
|
||
while time.time() < transit_deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
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 codeInput = Array.from(document.querySelectorAll(
|
||
'input[data-input-otp="true"], input[name="code"], input[autocomplete="one-time-code"], input[inputmode="numeric"], input[name="verificationCode"]'
|
||
)).find((n) => isVisible(n));
|
||
if (codeInput) return 'code-ready';
|
||
const emailInput = Array.from(document.querySelectorAll(
|
||
'input[data-testid="email"], input[name="email"], input[type="email"]'
|
||
)).find((n) => isVisible(n));
|
||
if (!emailInput) return 'email-gone';
|
||
const err = Array.from(document.querySelectorAll('[role="alert"], .error, [data-testid*="error"], [class*="error"]'))
|
||
.map((n) => (n.innerText || '').trim()).filter(Boolean)[0] || '';
|
||
if (err) return 'error:' + err.slice(0, 120);
|
||
const bodyTxt = (document.body && (document.body.innerText || '')) || '';
|
||
// xAI toast bubbles: [permission_denied] HTTP 403
|
||
if (/permission_denied|HTTP\s*403/i.test(bodyTxt)) return 'error:permission_denied HTTP 403';
|
||
if (/rate.?limit|too many|try again later|请求过多|稍后再试/i.test(bodyTxt)) return 'rate-limited';
|
||
const hasCf = !!document.querySelector(
|
||
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], input[name="cf-turnstile-response"], div.cf-turnstile, [data-sitekey]'
|
||
);
|
||
if (hasCf) return 'cf-challenge';
|
||
return 'waiting';
|
||
""")
|
||
except Exception:
|
||
state = "waiting"
|
||
last_state = str(state or "waiting")
|
||
if isinstance(state, str) and state.startswith("error:"):
|
||
if log_callback:
|
||
log_callback(f"[Debug] 邮箱提交后页面报错: {state}")
|
||
raise Exception(f"邮箱提交被拒绝: {state[6:]}")
|
||
if state == "rate-limited":
|
||
raise Exception("邮箱提交触发限流 rate-limited")
|
||
if state in ("code-ready", "email-gone"):
|
||
if log_callback:
|
||
log_callback(f"[*] 邮箱提交后页面状态: {state}")
|
||
dump_state(page, "email-submitted")
|
||
take_screenshot(page, "email-submitted")
|
||
return email, dev_token
|
||
# CF on email step: try turnstile once then continue waiting
|
||
if state == "cf-challenge" and not cf_tried:
|
||
cf_tried = True
|
||
if log_callback:
|
||
log_callback("[*] 邮箱步骤出现 Cloudflare,尝试通过 Turnstile...")
|
||
try:
|
||
getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback, max_rounds=5)
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 邮箱步骤 Turnstile 未过: {cf_exc}")
|
||
# Still on email form after a few seconds: re-click submit once
|
||
elapsed = confirm_sec - max(0.0, transit_deadline - time.time())
|
||
if not reclicked and elapsed >= 4.0 and state in ("waiting", "cf-challenge"):
|
||
reclicked = True
|
||
dismiss_cookie_banner(page, log_callback=log_callback)
|
||
try:
|
||
again = 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) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||
const submitButton = buttons.find((node) => {
|
||
const raw = (node.innerText || node.textContent || '').trim();
|
||
const compact = raw.replace(/\s+/g, '').toLowerCase();
|
||
return raw.includes('注册') || compact.includes('signup') || compact.includes('continue')
|
||
|| compact.includes('next') || compact.includes('submit') || compact.includes('createaccount');
|
||
});
|
||
if (!submitButton) return 'no-submit';
|
||
submitButton.click();
|
||
return 'reclicked';
|
||
"""
|
||
)
|
||
if log_callback:
|
||
log_callback(f"[*] 邮箱表单仍在,二次点击提交: {again}")
|
||
except Exception as re_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 二次提交失败: {re_exc}")
|
||
human_sleep(0.6, cancel_callback)
|
||
# Timed out without reaching code step — do NOT poll mail (email never sent)
|
||
try:
|
||
url_now = getattr(page, "url", "") or ""
|
||
except Exception:
|
||
url_now = ""
|
||
dump_state(page, "email-submit-stuck")
|
||
take_screenshot(page, "email-submit-stuck")
|
||
raise Exception(
|
||
f"邮箱提交后未进入验证码页 (state={last_state}, url={url_now[:120]})"
|
||
)
|
||
if log_callback and clicked not in (False, None, ""):
|
||
log_callback(f"[Debug] 邮箱已写入,但注册按钮未点到: {clicked}")
|
||
human_sleep(0.5, cancel_callback)
|
||
raise Exception("未找到邮箱输入框或注册按钮")
|
||
|
||
|
||
def fill_code_and_submit(email, dev_token, timeout=180, log_callback=None, cancel_callback=None):
|
||
page = _get_page()
|
||
check_timeout(time.time())
|
||
dump_state(page, "wait-code")
|
||
take_screenshot(page, "wait-code")
|
||
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;
|
||
"""
|
||
)
|
||
|
||
try:
|
||
mail_timeout = int(config.get("mail_timeout", timeout) or timeout)
|
||
except Exception:
|
||
mail_timeout = timeout
|
||
try:
|
||
mail_poll_interval = float(config.get("mail_poll_interval", 3) or 3)
|
||
except Exception:
|
||
mail_poll_interval = 3
|
||
|
||
code = get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=mail_timeout,
|
||
poll_interval=mail_poll_interval,
|
||
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":
|
||
human_sleep(0.5, cancel_callback)
|
||
continue
|
||
if "failed" in str(filled):
|
||
if log_callback:
|
||
log_callback(f"[Debug] 验证码填写失败: {filled}")
|
||
human_sleep(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}")
|
||
human_sleep(1.5, cancel_callback)
|
||
return code
|
||
|
||
human_sleep(0.5, cancel_callback)
|
||
|
||
raise Exception("验证码已获取,但自动填写/提交失败")
|
||
|
||
|
||
def _read_turnstile_token(page):
|
||
"""Read cf-turnstile-response / turnstile.getResponse if present.
|
||
|
||
Prefer DrissionPage native attr read (sees closed-shadow-ish values better),
|
||
then JS walk of open shadow roots + turnstile.getResponse().
|
||
"""
|
||
# Path 1: native element value (historical success tokens were length ~773)
|
||
for loc in (
|
||
"@name=cf-turnstile-response",
|
||
"css:input[name='cf-turnstile-response']",
|
||
"css:textarea[name='cf-turnstile-response']",
|
||
"tag:input@@name=cf-turnstile-response",
|
||
):
|
||
try:
|
||
el = page.ele(loc, timeout=0.15)
|
||
except Exception:
|
||
el = None
|
||
if el is None:
|
||
continue
|
||
try:
|
||
val = el.attr("value")
|
||
except Exception:
|
||
val = None
|
||
if not val:
|
||
try:
|
||
val = el.value
|
||
except Exception:
|
||
val = None
|
||
if not val:
|
||
try:
|
||
val = page.run_js("return arguments[0] ? String(arguments[0].value||'') : '';", el)
|
||
except Exception:
|
||
val = None
|
||
val = str(val or "").strip()
|
||
if len(val) >= 80:
|
||
return val
|
||
|
||
# Path 2: JS light DOM + open shadow + API
|
||
try:
|
||
token = page.run_js(
|
||
"""
|
||
try {
|
||
function walk(root, acc, depth) {
|
||
if (!root || depth > 6) return acc;
|
||
try {
|
||
const nodes = root.querySelectorAll
|
||
? root.querySelectorAll('input[name="cf-turnstile-response"], textarea[name="cf-turnstile-response"]')
|
||
: [];
|
||
for (const n of nodes) acc.push(n);
|
||
} catch (e) {}
|
||
let children = [];
|
||
try { children = root.querySelectorAll ? root.querySelectorAll('*') : []; } catch (e) { children = []; }
|
||
for (const el of children) {
|
||
try {
|
||
if (el.shadowRoot) walk(el.shadowRoot, acc, depth + 1);
|
||
} catch (e) {}
|
||
}
|
||
return acc;
|
||
}
|
||
const inputs = walk(document, [], 0);
|
||
for (const input of inputs) {
|
||
const v = String((input && input.value) || '').trim();
|
||
if (v) return v;
|
||
}
|
||
if (window.turnstile && typeof turnstile.getResponse === 'function') {
|
||
try {
|
||
const r = String(turnstile.getResponse() || '').trim();
|
||
if (r) return r;
|
||
} catch (e) {}
|
||
try {
|
||
const r2 = String(turnstile.getResponse('') || '').trim();
|
||
if (r2) return r2;
|
||
} catch (e) {}
|
||
}
|
||
return '';
|
||
} catch(e) { return ''; }
|
||
"""
|
||
)
|
||
return str(token or "").strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _turnstile_diag(page) -> dict:
|
||
"""Lightweight CF widget diagnostics for logs (no secrets).
|
||
|
||
Searches light DOM + open shadow roots. Detects managed-mode "Verifying..."
|
||
spinner text so callers can avoid reset/click storms.
|
||
"""
|
||
try:
|
||
info = page.run_js(
|
||
"""
|
||
try {
|
||
function walk(root, acc, depth) {
|
||
if (!root || depth > 6) return acc;
|
||
try {
|
||
const q = root.querySelectorAll
|
||
? root.querySelectorAll(
|
||
'input[name="cf-turnstile-response"], textarea[name="cf-turnstile-response"], ' +
|
||
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], ' +
|
||
'div.cf-turnstile, [data-sitekey], iframe[title*="Cloudflare"], iframe[title*="cloudflare"], iframe[title*="Widget"]'
|
||
)
|
||
: [];
|
||
for (const n of q) acc.push(n);
|
||
} catch (e) {}
|
||
let children = [];
|
||
try { children = root.querySelectorAll ? root.querySelectorAll('*') : []; } catch (e) { children = []; }
|
||
for (const el of children) {
|
||
try { if (el.shadowRoot) walk(el.shadowRoot, acc, depth + 1); } catch (e) {}
|
||
}
|
||
return acc;
|
||
}
|
||
const all = walk(document, [], 0);
|
||
const inputs = all.filter((n) => n && (n.tagName === 'INPUT' || n.tagName === 'TEXTAREA'));
|
||
const iframes = all.filter((n) => n && n.tagName === 'IFRAME');
|
||
const widgets = all.filter((n) => n && n.tagName !== 'IFRAME' && n.tagName !== 'INPUT' && n.tagName !== 'TEXTAREA');
|
||
const input = inputs[0] || document.querySelector('input[name="cf-turnstile-response"], textarea[name="cf-turnstile-response"]');
|
||
const srcs = iframes.slice(0, 3).map((f) => {
|
||
try { return String(f.src || f.getAttribute('src') || '').slice(0, 120); } catch(e) { return ''; }
|
||
});
|
||
const bodyText = ((document.body && document.body.innerText) || '').slice(0, 4000);
|
||
const verifying = /正在验证|verifying|checking|just a moment|请完成|人机验证/i.test(bodyText);
|
||
// success checkmark / aria
|
||
const successish = /成功|success|verified/i.test(bodyText) && !!input && String(input.value||'').trim().length >= 80;
|
||
let sitekey = '';
|
||
try {
|
||
const w = widgets[0] || document.querySelector('[data-sitekey], div.cf-turnstile');
|
||
if (w) sitekey = String(w.getAttribute('data-sitekey') || '').slice(0, 16);
|
||
} catch (e) {}
|
||
// iframe box size (0x0 means not painted)
|
||
let iframeBox = '';
|
||
try {
|
||
if (iframes[0]) {
|
||
const r = iframes[0].getBoundingClientRect();
|
||
iframeBox = Math.round(r.width) + 'x' + Math.round(r.height);
|
||
}
|
||
} catch (e) {}
|
||
return {
|
||
hasInput: !!input,
|
||
tokenLen: input ? String(input.value || '').trim().length : 0,
|
||
iframeCount: iframes.length,
|
||
widgetCount: widgets.length,
|
||
sitekey: sitekey,
|
||
iframeSrc0: srcs[0] || '',
|
||
iframeBox: iframeBox,
|
||
turnstileApi: !!(window.turnstile && typeof turnstile.getResponse === 'function'),
|
||
webdriver: !!navigator.webdriver,
|
||
verifying: verifying,
|
||
successish: successish,
|
||
};
|
||
} catch(e) {
|
||
return { err: String(e && e.message || e) };
|
||
}
|
||
"""
|
||
)
|
||
return info if isinstance(info, dict) else {"raw": str(info)[:200]}
|
||
except Exception as exc:
|
||
return {"err": str(exc)[:160]}
|
||
|
||
|
||
def _click_turnstile_widget(page, log_callback=None) -> str:
|
||
"""Best-effort click path for interactive Turnstile checkbox.
|
||
|
||
Ported from ReinerBRO/grok-register + Git-creat7/grokRegister-cpa:
|
||
input[name=cf-turnstile-response] → parent shadow → iframe
|
||
→ patch screenX/Y inside iframe → body.shadow_root → input.click()
|
||
|
||
Returns a short tag describing which path succeeded (or 'none').
|
||
"""
|
||
# Path A: classic ReinerBRO shadow-root checkbox
|
||
try:
|
||
challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.6)
|
||
except Exception:
|
||
challenge_input = None
|
||
if challenge_input:
|
||
iframe = None
|
||
try:
|
||
wrapper = challenge_input.parent()
|
||
iframe = wrapper.shadow_root.ele("tag:iframe")
|
||
except Exception:
|
||
iframe = None
|
||
if iframe is None:
|
||
try:
|
||
iframe = page.ele(
|
||
'css:iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"]',
|
||
timeout=0.6,
|
||
)
|
||
except Exception:
|
||
iframe = None
|
||
if iframe:
|
||
# Match ReinerBRO exactly: value: not get: (CF reads own props).
|
||
try:
|
||
iframe.run_js(
|
||
"""
|
||
window.dtp = 1;
|
||
function getRandomInt(min, max) {
|
||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||
}
|
||
let screenX = getRandomInt(800, 1200);
|
||
let screenY = getRandomInt(400, 600);
|
||
Object.defineProperty(MouseEvent.prototype, 'screenX', { value: screenX });
|
||
Object.defineProperty(MouseEvent.prototype, 'screenY', { value: screenY });
|
||
try {
|
||
if (typeof PointerEvent !== 'undefined' && PointerEvent.prototype) {
|
||
Object.defineProperty(PointerEvent.prototype, 'screenX', { value: screenX });
|
||
Object.defineProperty(PointerEvent.prototype, 'screenY', { value: screenY });
|
||
}
|
||
} catch (e) {}
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
# A1: classic ReinerBRO path — body.shadow_root → tag:input → click
|
||
try:
|
||
body_sr = iframe.ele("tag:body").shadow_root
|
||
btn = body_sr.ele("tag:input") or body_sr.ele("css:.mark") or body_sr.ele("css:label")
|
||
if btn:
|
||
try:
|
||
btn.click()
|
||
except Exception:
|
||
try:
|
||
btn.click(by_js=True)
|
||
except Exception:
|
||
page.actions.click(btn)
|
||
return "shadow-checkbox"
|
||
except Exception:
|
||
pass
|
||
# A2: click iframe element itself (center)
|
||
try:
|
||
iframe.click()
|
||
return "iframe-click"
|
||
except Exception:
|
||
pass
|
||
try:
|
||
page.actions.click(iframe)
|
||
return "iframe-actions"
|
||
except Exception:
|
||
pass
|
||
|
||
# Path B: DOM query + synthetic pointer events (Git-creat7 fallback)
|
||
try:
|
||
clicked = page.run_js(
|
||
"""
|
||
const nodes = Array.from(document.querySelectorAll(
|
||
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey]'
|
||
)).filter((n) => {
|
||
const txt = (n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || '');
|
||
return String(txt).toLowerCase().includes('turnstile')
|
||
|| String(n.getAttribute?.('src') || '').includes('challenges.cloudflare.com')
|
||
|| n.hasAttribute?.('data-sitekey');
|
||
});
|
||
let ok = false;
|
||
for (const n of nodes) {
|
||
try {
|
||
if (typeof n.click === 'function') { n.click(); ok = true; }
|
||
} catch (e) {}
|
||
}
|
||
return ok;
|
||
"""
|
||
)
|
||
if clicked:
|
||
return "js-click"
|
||
except Exception:
|
||
pass
|
||
return "none"
|
||
|
||
|
||
def getTurnstileToken(log_callback=None, cancel_callback=None, max_rounds: int = 20):
|
||
"""batch100-era solver (55f56d2/c56d0d2) with longer waits.
|
||
|
||
Historical success: Google Chrome + CDP stealth, Accept All Cookies,
|
||
reset + shadow checkbox click, token ~773.
|
||
"""
|
||
page = _get_page()
|
||
if page is None:
|
||
raise Exception("页面未就绪,无法执行 Turnstile")
|
||
apply_stealth_patches(page, log_callback=None)
|
||
|
||
try:
|
||
page.run_js(
|
||
"try { if (window.turnstile && typeof turnstile.reset === 'function') turnstile.reset(); } catch(e) {}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
rounds = max(8, int(max_rounds or 20))
|
||
# Initial settle for widget attach
|
||
cf_sleep(1.2 if not PERF_FLAGS.get("fast") else 0.9, cancel_callback, floor=0.7)
|
||
|
||
for round_i in range(0, rounds):
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
token = _read_turnstile_token(page)
|
||
if len(token) >= 80:
|
||
if log_callback:
|
||
log_callback(f"[*] Turnstile 已通过,token长度={len(token)}")
|
||
return token
|
||
|
||
# Historical pure JS read path as well
|
||
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
|
||
|
||
path = _click_turnstile_widget(page, log_callback=log_callback)
|
||
if log_callback and (round_i == 0 or round_i % 3 == 0):
|
||
log_callback(f"[Debug] Turnstile click path={path} round={round_i+1}/{rounds}")
|
||
except Exception:
|
||
pass
|
||
# Progressive wait like c56d0d2, but wall-clock via cf_sleep
|
||
cf_sleep(0.9 + 0.15 * min(round_i, 6), cancel_callback, floor=0.8)
|
||
|
||
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):
|
||
"""Fill profile form and submit after Turnstile is solved.
|
||
|
||
CF wait / retry loop follows Git-creat7/grokRegister-cpa:
|
||
fill → if wait-cloudflare → after ~12s call getTurnstileToken (reset+click)
|
||
→ re-fill token into hidden input → submit.
|
||
"""
|
||
page = _get_page()
|
||
apply_stealth_patches(page, log_callback=log_callback)
|
||
check_timeout(time.time())
|
||
# Cookie overlay often reappears on profile step and blanks Turnstile (iframe=0).
|
||
dismiss_cookie_banner(page, log_callback=log_callback)
|
||
dump_state(page, "profile-form")
|
||
take_screenshot(page, "profile-form")
|
||
given_name, family_name, password = build_profile()
|
||
# 预热 Turnstile:cookie 只关一次(sticky),然后给 Verifying 时间
|
||
if log_callback:
|
||
log_callback("[*] 预热 Turnstile...")
|
||
try:
|
||
dismiss_cookie_banner(page, log_callback=log_callback)
|
||
except Exception:
|
||
pass
|
||
# 必须用 cf_sleep:--fast 下 human_sleep 会把预热压到 <0.3s,iframe 来不及挂载
|
||
cf_sleep(2.4 if not PERF_FLAGS.get("fast") else 1.6, cancel_callback, floor=1.2)
|
||
try:
|
||
pre = _read_turnstile_token(page)
|
||
if pre and len(pre) >= 80 and log_callback:
|
||
log_callback(f"[*] 预热阶段已有 token,长度={len(pre)}")
|
||
else:
|
||
diag_pre = _turnstile_diag(page)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] 预热后 Turnstile: tokenLen={diag_pre.get('tokenLen', 0)} "
|
||
f"hasInput={diag_pre.get('hasInput')} api={diag_pre.get('turnstileApi')} "
|
||
f"iframe={diag_pre.get('iframeCount', 0)}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
deadline = time.time() + timeout
|
||
form_filled_once = False
|
||
wait_cf_since = None
|
||
last_cf_retry_at = 0.0
|
||
last_cf_log_at = 0.0
|
||
cf_token_fail_streak = 0
|
||
try:
|
||
# Git-creat7 风格:卡住就二次复用;允许多轮,别太早放弃
|
||
max_cf_token_fails = max(3, int(config.get("turnstile_fast_fail_count", 3) or 3))
|
||
except Exception:
|
||
max_cf_token_fails = 3
|
||
try:
|
||
# 参考实现:等 12s 后开始 getTurnstileToken,间隔 8s
|
||
cf_retry_after = float(config.get("turnstile_retry_after_sec", 45) or 45)
|
||
except Exception:
|
||
cf_retry_after = 12.0
|
||
try:
|
||
cf_retry_gap = float(config.get("turnstile_retry_gap_sec", 8) or 8)
|
||
except Exception:
|
||
cf_retry_gap = 8.0
|
||
try:
|
||
# 参考 15-20 轮 reset+click;默认抬到 16
|
||
turnstile_rounds = max(12, int(config.get("turnstile_max_rounds", 16) or 16))
|
||
except Exception:
|
||
turnstile_rounds = 16
|
||
|
||
def _sync_cf_token(token: str):
|
||
return 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,
|
||
)
|
||
|
||
def _retry_turnstile(reason: str) -> bool:
|
||
nonlocal cf_token_fail_streak, last_cf_retry_at
|
||
if log_callback:
|
||
log_callback(f"[*] {reason}")
|
||
try:
|
||
token = getTurnstileToken(
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
max_rounds=turnstile_rounds,
|
||
)
|
||
if token:
|
||
cf_token_fail_streak = 0
|
||
synced = _sync_cf_token(token)
|
||
if log_callback:
|
||
log_callback(f"[*] Turnstile 二次复用完成,回填长度={synced}")
|
||
last_cf_retry_at = time.time()
|
||
return True
|
||
except Exception as cf_exc:
|
||
cf_token_fail_streak += 1
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] Turnstile 二次复用失败({cf_token_fail_streak}/{max_cf_token_fails}): {cf_exc}"
|
||
)
|
||
if cf_token_fail_streak >= max_cf_token_fails:
|
||
raise Exception(f"Turnstile 连续失败,快速放弃资料页: {cf_exc}")
|
||
last_cf_retry_at = time.time()
|
||
return False
|
||
|
||
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') || t.includes('completesignup');
|
||
});
|
||
|
||
// 必须等待 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
|
||
now = time.time()
|
||
if wait_cf_since is None:
|
||
wait_cf_since = now
|
||
token_len = filled.split(":", 1)[1] if ":" in filled else "0"
|
||
if log_callback and (last_cf_log_at <= 0 or now - last_cf_log_at >= 5):
|
||
waited = now - wait_cf_since if wait_cf_since else 0
|
||
log_callback(
|
||
f"[*] 资料已填写,等待 Cloudflare 人机验证通过... "
|
||
f"当前token长度={token_len} waited={waited:.0f}s"
|
||
)
|
||
last_cf_log_at = now
|
||
# Git-creat7: token 为空时多等 1-3s
|
||
if token_len == "0":
|
||
pause_seconds = random.uniform(1.0, 3.0)
|
||
if log_callback and (now - last_cf_log_at >= 4):
|
||
log_callback(f"[*] Cloudflare token 为空,暂停 {pause_seconds:.1f}s 后继续检测")
|
||
last_cf_log_at = now
|
||
sleep_with_cancel(pause_seconds, cancel_callback)
|
||
# Cookie overlay can reappear mid-wait and keep iframe=0
|
||
if wait_cf_since and (now - wait_cf_since) >= 2.0:
|
||
try:
|
||
dismiss_cookie_banner(page, log_callback=None)
|
||
except Exception:
|
||
pass
|
||
# 轻点一次(参考会在 getTurnstileToken 内反复点;外层也补一次)
|
||
if now - wait_cf_since >= 3.0 and now - last_cf_retry_at >= 3.0:
|
||
try:
|
||
_click_turnstile_widget(page)
|
||
except Exception:
|
||
pass
|
||
# 卡住后二次复用 Turnstile 组件(reset + shadow click)
|
||
if now - wait_cf_since >= cf_retry_after and now - last_cf_retry_at >= cf_retry_gap:
|
||
_retry_turnstile("Cloudflare 验证卡住,开始二次复用 Turnstile...")
|
||
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] 资料输入失败,重试中...")
|
||
human_sleep(0.5, cancel_callback)
|
||
continue
|
||
elif filled == "not-ready":
|
||
human_sleep(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') || t.includes('completesignup');
|
||
});
|
||
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"):
|
||
now = time.time()
|
||
if wait_cf_since is None:
|
||
wait_cf_since = now
|
||
if log_callback and (last_cf_log_at <= 0 or now - last_cf_log_at >= 5):
|
||
token_len = submit_state.split(":", 1)[1] if ":" in submit_state else "0"
|
||
waited = now - wait_cf_since if wait_cf_since else 0
|
||
log_callback(
|
||
f"[*] 等待 Cloudflare 人机验证通过后再提交... "
|
||
f"当前token长度={token_len} waited={waited:.0f}s"
|
||
)
|
||
last_cf_log_at = now
|
||
if wait_cf_since and now - wait_cf_since >= 3.0 and now - last_cf_retry_at >= 3.0:
|
||
try:
|
||
_click_turnstile_widget(page)
|
||
except Exception:
|
||
pass
|
||
if now - wait_cf_since >= cf_retry_after and now - last_cf_retry_at >= cf_retry_gap:
|
||
_retry_turnstile("提交前仍卡住,自动再次复用 Turnstile...")
|
||
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:
|
||
log_callback(f"[Debug] 未找到提交按钮,继续等待页面稳定... {submit_state[17:80]}")
|
||
|
||
human_sleep(0.5, cancel_callback)
|
||
|
||
raise Exception("最终注册页资料填写失败")
|
||
|
||
|
||
# ── NSFW 自动开启(从 AaronL725 移植) ──
|
||
|
||
|
||
def generate_random_birthdate():
|
||
"""生成随机生日(20-40 岁)。"""
|
||
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 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}")
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
return False, f"set_birth_date HTTP {res.status_code}"
|
||
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):
|
||
"""同意 TOS。"""
|
||
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"
|
||
return False, f"set_tos_accepted HTTP {res.status_code}"
|
||
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():
|
||
"""编码 NSFW 设置 gRPC 请求体。"""
|
||
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):
|
||
"""更新 NSFW 设置。"""
|
||
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}")
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
return False, f"update_nsfw_settings HTTP {res.status_code}"
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[update_nsfw] 异常: {e}")
|
||
return False, f"update_nsfw_settings 异常: {e}"
|
||
|
||
|
||
def enable_nsfw(sso_cookie, log_callback=None):
|
||
"""使用 sso cookie 自动开启 NSFW(生日 + TOS + NSFW 设置)。"""
|
||
from curl_cffi import requests as cf_requests
|
||
session = cf_requests.Session()
|
||
session.cookies.set("sso", sso_cookie, domain="grok.com")
|
||
session.cookies.set("sso", sso_cookie, domain="accounts.x.ai")
|
||
|
||
results = {}
|
||
|
||
# 1. 设置生日
|
||
ok, msg = set_birth_date(session, log_callback=log_callback)
|
||
results["set_birth_date"] = {"ok": ok, "msg": msg}
|
||
|
||
# 2. 同意 TOS
|
||
ok, msg = set_tos_accepted(session, log_callback=log_callback)
|
||
results["set_tos_accepted"] = {"ok": ok, "msg": msg}
|
||
|
||
# 3. 开启 NSFW
|
||
ok, msg = update_nsfw_settings(session, log_callback=log_callback)
|
||
results["update_nsfw_settings"] = {"ok": ok, "msg": msg}
|
||
|
||
if log_callback:
|
||
all_ok = all(r["ok"] for r in results.values())
|
||
log_callback(f"[*] NSFW 设置: {'全部成功' if all_ok else results}")
|
||
|
||
return results
|
||
|
||
|
||
# ── wait_for_sso_cookie ──
|
||
|
||
|
||
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
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
page = _get_page()
|
||
if page is None:
|
||
human_sleep(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, '');
|
||
return t.includes('完成注册');
|
||
});
|
||
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;
|
||
}
|
||
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button')).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('signup') || t.includes('createaccount');
|
||
});
|
||
if (!submitBtn) return 'final-page-no-submit';
|
||
submitBtn.focus();
|
||
submitBtn.click();
|
||
return 'final-page-clicked-submit';
|
||
"""
|
||
)
|
||
last_submit_retry = now
|
||
if log_callback and retried in ("final-page-no-submit", "final-page-clicked-submit"):
|
||
log_callback(f"[Debug] 最终页状态: {retried}")
|
||
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 in ("sso", "sso-rw") and value:
|
||
if log_callback:
|
||
log_callback(f"[*] 已获取到 {name} cookie")
|
||
return value
|
||
except PageDisconnectedError:
|
||
refresh_active_page()
|
||
except Exception:
|
||
pass
|
||
|
||
human_sleep(1, cancel_callback)
|
||
|
||
raise Exception(
|
||
f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}"
|
||
)
|
||
|
||
|
||
# ── 登录(非注册)获取 sso ──
|
||
|
||
LOGIN_URL = "https://accounts.x.ai/login?redirect=grok-com"
|
||
|
||
|
||
def open_login_page(log_callback=None, cancel_callback=None):
|
||
"""打开 xAI 登录页,点击「使用邮箱登录」。"""
|
||
browser = _get_browser()
|
||
page = _get_page()
|
||
raise_if_cancelled(cancel_callback)
|
||
if browser is None:
|
||
browser, page = start_browser()
|
||
if log_callback:
|
||
log_callback("[*] 浏览器已启动")
|
||
try:
|
||
page = _get_page()
|
||
page.get(LOGIN_URL)
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 打开URL异常: {e}")
|
||
restart_browser()
|
||
page = _get_page()
|
||
page.get(LOGIN_URL)
|
||
page.wait.doc_loaded()
|
||
human_sleep(2, cancel_callback)
|
||
if log_callback:
|
||
log_callback(f"[*] 当前URL: {page.url}")
|
||
# 点击「使用邮箱登录」
|
||
clicked = page.run_js("""
|
||
const btn = document.querySelector('button[data-testid="continue-with-email"]');
|
||
if (btn) { btn.click(); return 'clicked'; }
|
||
return 'not-found';
|
||
""")
|
||
if clicked != 'clicked':
|
||
raise Exception("未找到「使用邮箱登录」按钮")
|
||
human_sleep(2, cancel_callback)
|
||
if log_callback:
|
||
log_callback("[*] 已点击「使用邮箱登录」")
|
||
|
||
|
||
def fill_login_and_submit(email, password, timeout=120, log_callback=None, cancel_callback=None):
|
||
"""两步登录:1.填邮箱点下一步 2.填密码处理Turnstile点登录。"""
|
||
page = _get_page()
|
||
deadline = time.time() + timeout
|
||
last_cf_retry = 0.0
|
||
|
||
# ── 步骤1:填邮箱,点「下一步」 ──
|
||
email_submitted = False
|
||
while time.time() < deadline and not email_submitted:
|
||
raise_if_cancelled(cancel_callback)
|
||
state = page.run_js("""
|
||
const emailInput = document.querySelector('input[data-testid="email"]');
|
||
if (!emailInput) return 'not-ready';
|
||
const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
const tracker = emailInput._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (ns) ns.call(emailInput, arguments[0]); else emailInput.value = arguments[0];
|
||
emailInput.dispatchEvent(new InputEvent('input', {bubbles:true, data:arguments[0], inputType:'insertText'}));
|
||
emailInput.dispatchEvent(new Event('change', {bubbles:true}));
|
||
emailInput.blur();
|
||
if (String(emailInput.value||'').trim() !== String(arguments[0]||'').trim()) return 'fill-failed';
|
||
const btn = document.querySelector('button[data-testid="sign-in-submit"]');
|
||
if (!btn) return 'no-btn';
|
||
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'btn-disabled';
|
||
btn.click();
|
||
return 'submitted';
|
||
""", email)
|
||
if state == 'submitted':
|
||
email_submitted = True
|
||
if log_callback:
|
||
log_callback(f"[*] 已填写邮箱并提交: {email}")
|
||
elif state == 'not-ready':
|
||
human_sleep(0.5, cancel_callback)
|
||
elif state == 'btn-disabled':
|
||
human_sleep(0.5, cancel_callback)
|
||
else:
|
||
human_sleep(0.5, cancel_callback)
|
||
if not email_submitted:
|
||
raise Exception("邮箱提交超时")
|
||
|
||
# 等密码框出现
|
||
human_sleep(2, cancel_callback)
|
||
|
||
# ── 步骤2:填密码,处理 Turnstile,点「登录」 ──
|
||
pw_filled = False
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if not pw_filled:
|
||
filled = page.run_js("""
|
||
const pwInput = document.querySelector('input[data-testid="password"]');
|
||
if (!pwInput) return 'not-ready';
|
||
const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
const tracker = pwInput._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (ns) ns.call(pwInput, arguments[0]); else pwInput.value = arguments[0];
|
||
pwInput.dispatchEvent(new InputEvent('input', {bubbles:true, data:arguments[0], inputType:'insertText'}));
|
||
pwInput.dispatchEvent(new Event('change', {bubbles:true}));
|
||
pwInput.blur();
|
||
if (String(pwInput.value||'').trim() !== String(arguments[0]||'').trim()) return 'fill-failed';
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile');
|
||
if (cfPresent) {
|
||
const token = String((cfInput && cfInput.value) || '').trim();
|
||
if (token.length < 80) return 'wait-cf:' + token.length;
|
||
}
|
||
return 'ready';
|
||
""", password)
|
||
if isinstance(filled, str) and filled.startswith('wait-cf'):
|
||
pw_filled = True
|
||
if log_callback:
|
||
token_len = filled.split(':',1)[1] if ':' in filled else '0'
|
||
log_callback(f"[*] 已填密码,等待 Turnstile... token长度={token_len}")
|
||
now = time.time()
|
||
if now - last_cf_retry >= 8:
|
||
try:
|
||
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
|
||
if token:
|
||
page.run_js("""
|
||
const token = String(arguments[0]||'').trim();
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
if (cfInput && token) {
|
||
const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
if (ns) ns.call(cfInput, token); else cfInput.value = token;
|
||
cfInput.dispatchEvent(new Event('input', {bubbles:true}));
|
||
cfInput.dispatchEvent(new Event('change', {bubbles:true}));
|
||
}
|
||
""", token)
|
||
if log_callback:
|
||
log_callback("[*] Turnstile 已通过,回填完成")
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 复用失败: {cf_exc}")
|
||
last_cf_retry = now
|
||
human_sleep(1, cancel_callback)
|
||
continue
|
||
elif filled == 'ready':
|
||
pw_filled = True
|
||
if log_callback:
|
||
log_callback("[*] 密码已填写,准备提交")
|
||
elif filled == 'not-ready':
|
||
human_sleep(0.5, cancel_callback)
|
||
continue
|
||
elif filled == 'fill-failed':
|
||
human_sleep(0.5, cancel_callback)
|
||
continue
|
||
|
||
# 提交
|
||
state = page.run_js("""
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
const cfPresent = !!cfInput || !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile');
|
||
if (cfPresent) {
|
||
const token = String((cfInput && cfInput.value) || '').trim();
|
||
if (token.length < 80) return 'wait-cf:' + token.length;
|
||
}
|
||
const btn = document.querySelector('button[data-testid="sign-in-submit"]');
|
||
if (!btn) return 'no-submit';
|
||
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'btn-disabled';
|
||
btn.click();
|
||
return 'submitted';
|
||
""")
|
||
if isinstance(state, str) and state.startswith('wait-cf'):
|
||
if log_callback:
|
||
token_len = state.split(':',1)[1] if ':' in state else '0'
|
||
log_callback(f"[*] 等待 Turnstile 通过后再提交... token长度={token_len}")
|
||
now = time.time()
|
||
if now - last_cf_retry >= 8:
|
||
try:
|
||
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
|
||
if token:
|
||
page.run_js("""
|
||
const token = String(arguments[0]||'').trim();
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
if (cfInput && token) {
|
||
const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
if (ns) ns.call(cfInput, token); else cfInput.value = token;
|
||
cfInput.dispatchEvent(new Event('input', {bubbles:true}));
|
||
cfInput.dispatchEvent(new Event('change', {bubbles:true}));
|
||
}
|
||
""", token)
|
||
if log_callback:
|
||
log_callback("[*] Turnstile 二次复用完成")
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Turnstile 复用失败: {cf_exc}")
|
||
last_cf_retry = now
|
||
human_sleep(1, cancel_callback)
|
||
continue
|
||
elif state == 'submitted':
|
||
if log_callback:
|
||
log_callback("[*] 已点击登录,等待 sso cookie...")
|
||
return
|
||
elif state == 'btn-disabled':
|
||
human_sleep(1, cancel_callback)
|
||
continue
|
||
human_sleep(1, cancel_callback)
|
||
raise Exception("登录提交超时")
|
||
|
||
def login_and_get_sso(email, password, log_callback=None, cancel_callback=None):
|
||
"""完整登录流程:打开页 → 填邮箱密码 → Turnstile → 等 sso cookie。"""
|
||
open_login_page(log_callback=log_callback, cancel_callback=cancel_callback)
|
||
fill_login_and_submit(email, password, log_callback=log_callback, cancel_callback=cancel_callback)
|
||
sso = wait_for_sso_cookie(timeout=120, log_callback=log_callback, cancel_callback=cancel_callback)
|
||
return sso
|
||
|
||
|
||
def export_cpa_after_success(email, password, sso, page=None, log_callback=None):
|
||
"""GUI 成功注册后的 CPA xai-*.json 导出 hook。"""
|
||
log = log_callback or (lambda m: print(m, flush=True))
|
||
if not config.get("cpa_export_enabled", True):
|
||
log("[cpa] export disabled, skip")
|
||
return {"ok": False, "skipped": True, "reason": "disabled"}
|
||
if not email or not password:
|
||
log("[cpa] 缺少 email/password,跳过 CPA 导出")
|
||
return {"ok": False, "error": "missing email/password"}
|
||
try:
|
||
import cpa_export
|
||
except Exception as exc:
|
||
log(f"[cpa] 导入 cpa_export 失败: {exc}")
|
||
return {"ok": False, "error": f"import: {exc}"}
|
||
|
||
cookies = []
|
||
try:
|
||
cookies = cpa_export.export_cookies_from_page(page) if page is not None else []
|
||
except Exception as exc:
|
||
log(f"[cpa] cookie 导出失败,继续用邮箱密码 mint: {exc}")
|
||
cookies = []
|
||
if cookies:
|
||
log(f"[cpa] 已导出 cookie {len(cookies)} 条供 OIDC mint 注入")
|
||
|
||
cpa_cfg = dict(config)
|
||
if _config_bool(config.get("cpa_gui_close_mint_browser", True), default=True):
|
||
# GUI 下优先不残留额外 Chromium:CPA mint 成功后也立即 quit。
|
||
# CLI 仍可通过 cpa_mint_browser_reuse 保持流水线复用。
|
||
cpa_cfg["cpa_mint_browser_reuse"] = False
|
||
|
||
# GUI 可多线程注册;CPA mint 使用独立有头浏览器,串行可避免多个 consent 窗口互相抢焦点。
|
||
with _cpa_gui_export_lock:
|
||
try:
|
||
result = cpa_export.export_cpa_xai_for_account(
|
||
email,
|
||
password,
|
||
page=page,
|
||
cookies=cookies,
|
||
sso=sso,
|
||
config=cpa_cfg,
|
||
log_callback=log,
|
||
)
|
||
except Exception as exc:
|
||
log(f"[cpa] CPA 导出异常: {exc}")
|
||
if config.get("cpa_mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": str(exc)}
|
||
|
||
if result.get("ok"):
|
||
log(f"[cpa] CPA 格式已导出: {result.get('path')}")
|
||
else:
|
||
log(f"[cpa] CPA 导出失败: {result.get('error') or result}")
|
||
return result
|
||
|
||
|
||
class GrokRegisterGUI:
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title("Grok 注册机")
|
||
self.root.geometry("980x860")
|
||
self.root.minsize(900, 760)
|
||
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.stats_lock = threading.Lock()
|
||
self._tutorial_window = None
|
||
self.setup_ui()
|
||
self.root.after(200, self._maybe_show_tutorial_on_start)
|
||
|
||
def setup_ui(self):
|
||
load_config()
|
||
main_frame = ttk.Frame(self.root, padding=10)
|
||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||
config_frame = ttk.LabelFrame(main_frame, text="配置", padding=10)
|
||
config_frame.pack(fill=tk.X, pady=5)
|
||
ttk.Label(config_frame, text="邮箱服务商:").grid(row=0, column=0, sticky=tk.W)
|
||
self.email_provider_var = tk.StringVar(value=config.get("email_provider", "duckmail"))
|
||
self.email_provider_combo = ttk.Combobox(config_frame, textvariable=self.email_provider_var, values=["duckmail", "yyds", "cloudflare", "cloudmail", "hotmail", "outlookmail", "gmail"], width=12, state="readonly")
|
||
self.email_provider_combo.grid(row=0, column=1, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="注册数量:").grid(row=0, column=2, sticky=tk.W, padx=10)
|
||
self.count_var = tk.StringVar(value=str(config.get("register_count", 1)))
|
||
self.count_spinbox = ttk.Spinbox(config_frame, from_=1, to=100, width=8, textvariable=self.count_var)
|
||
self.count_spinbox.grid(row=0, column=3, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="并发线程:").grid(row=1, column=2, sticky=tk.W, padx=10)
|
||
self.thread_var = tk.StringVar(value=str(config.get("register_threads", 1)))
|
||
self.thread_spinbox = ttk.Spinbox(config_frame, from_=1, to=10, width=8, textvariable=self.thread_var)
|
||
self.thread_spinbox.grid(row=1, column=3, sticky=tk.W, padx=5)
|
||
self.nsfw_var = tk.BooleanVar(value=config.get("enable_nsfw", True))
|
||
self.nsfw_check = ttk.Checkbutton(config_frame, text="注册后开启 NSFW", variable=self.nsfw_var)
|
||
self.nsfw_check.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=5)
|
||
ttk.Label(config_frame, text="代理(可选):").grid(row=2, column=0, sticky=tk.W)
|
||
self.proxy_var = tk.StringVar(value=config.get("proxy", ""))
|
||
self.proxy_entry = ttk.Entry(config_frame, textvariable=self.proxy_var, width=30)
|
||
self.proxy_entry.grid(row=2, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="DuckMail API Key:").grid(row=3, column=0, sticky=tk.W)
|
||
self.api_key_var = tk.StringVar(value=config.get("duckmail_api_key", ""))
|
||
self.api_key_entry = ttk.Entry(config_frame, textvariable=self.api_key_var, width=30)
|
||
self.api_key_entry.grid(row=3, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="Cloudflare API Base:").grid(row=4, column=0, sticky=tk.W)
|
||
self.cloudflare_api_base_var = tk.StringVar(value=config.get("cloudflare_api_base", ""))
|
||
self.cloudflare_api_base_entry = ttk.Entry(config_frame, textvariable=self.cloudflare_api_base_var, width=30)
|
||
self.cloudflare_api_base_entry.grid(row=4, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="Cloudflare API Key:").grid(row=5, column=0, sticky=tk.W)
|
||
self.cloudflare_api_key_var = tk.StringVar(value=config.get("cloudflare_api_key", ""))
|
||
self.cloudflare_api_key_entry = ttk.Entry(config_frame, textvariable=self.cloudflare_api_key_var, width=30)
|
||
self.cloudflare_api_key_entry.grid(row=5, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="Cloudflare 鉴权模式:").grid(row=6, column=0, sticky=tk.W)
|
||
self.cloudflare_auth_mode_var = tk.StringVar(value=config.get("cloudflare_auth_mode", "bearer"))
|
||
self.cloudflare_auth_mode_combo = ttk.Combobox(
|
||
config_frame,
|
||
textvariable=self.cloudflare_auth_mode_var,
|
||
values=["query-key", "bearer", "x-api-key", "none"],
|
||
width=12,
|
||
state="readonly",
|
||
)
|
||
self.cloudflare_auth_mode_combo.grid(row=6, column=1, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="CF 路径(domains/accounts/token/messages):").grid(row=7, column=0, sticky=tk.W)
|
||
self.cloudflare_paths_var = tk.StringVar(
|
||
value=",".join(
|
||
[
|
||
config.get("cloudflare_path_domains", "/domains"),
|
||
config.get("cloudflare_path_accounts", "/accounts"),
|
||
config.get("cloudflare_path_token", "/token"),
|
||
config.get("cloudflare_path_messages", "/messages"),
|
||
]
|
||
)
|
||
)
|
||
self.cloudflare_paths_entry = ttk.Entry(config_frame, textvariable=self.cloudflare_paths_var, width=30)
|
||
self.cloudflare_paths_entry.grid(row=7, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="CloudMail URL:").grid(row=8, column=0, sticky=tk.W)
|
||
self.cloudmail_url_var = tk.StringVar(value=str(config.get("cloudmail_url", "")))
|
||
self.cloudmail_url_entry = ttk.Entry(config_frame, textvariable=self.cloudmail_url_var, width=30)
|
||
self.cloudmail_url_entry.grid(row=8, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="CloudMail 管理员邮箱:").grid(row=9, column=0, sticky=tk.W)
|
||
self.cloudmail_admin_email_var = tk.StringVar(value=str(config.get("cloudmail_admin_email", "")))
|
||
self.cloudmail_admin_email_entry = ttk.Entry(config_frame, textvariable=self.cloudmail_admin_email_var, width=30)
|
||
self.cloudmail_admin_email_entry.grid(row=9, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="CloudMail 管理员密码:").grid(row=10, column=0, sticky=tk.W)
|
||
self.cloudmail_password_var = tk.StringVar(value=str(config.get("cloudmail_password", "")))
|
||
self.cloudmail_password_entry = ttk.Entry(config_frame, textvariable=self.cloudmail_password_var, width=30, show="*")
|
||
self.cloudmail_password_entry.grid(row=10, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="grok2api 本地自动入池:").grid(row=11, column=0, sticky=tk.W)
|
||
self.grok2api_local_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_local", True)))
|
||
self.grok2api_local_auto_check = ttk.Checkbutton(config_frame, variable=self.grok2api_local_auto_var)
|
||
self.grok2api_local_auto_check.grid(row=11, column=1, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="grok2api 本地 token.json:").grid(row=12, column=0, sticky=tk.W)
|
||
self.grok2api_local_file_var = tk.StringVar(value=str(config.get("grok2api_local_token_file", "")))
|
||
self.grok2api_local_file_entry = ttk.Entry(config_frame, textvariable=self.grok2api_local_file_var, width=30)
|
||
self.grok2api_local_file_entry.grid(row=12, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="grok2api 池名:").grid(row=13, column=0, sticky=tk.W)
|
||
self.grok2api_pool_name_var = tk.StringVar(value=str(config.get("grok2api_pool_name", "ssoBasic")))
|
||
self.grok2api_pool_name_combo = ttk.Combobox(
|
||
config_frame,
|
||
textvariable=self.grok2api_pool_name_var,
|
||
values=["ssoBasic", "ssoSuper"],
|
||
width=12,
|
||
state="readonly",
|
||
)
|
||
self.grok2api_pool_name_combo.grid(row=13, column=1, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="grok2api 远端自动入池:").grid(row=14, column=0, sticky=tk.W)
|
||
self.grok2api_remote_auto_var = tk.BooleanVar(value=bool(config.get("grok2api_auto_add_remote", False)))
|
||
self.grok2api_remote_auto_check = ttk.Checkbutton(config_frame, variable=self.grok2api_remote_auto_var)
|
||
self.grok2api_remote_auto_check.grid(row=14, column=1, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="grok2api 远端 Base:").grid(row=15, column=0, sticky=tk.W)
|
||
self.grok2api_remote_base_var = tk.StringVar(value=str(config.get("grok2api_remote_base", "")))
|
||
self.grok2api_remote_base_entry = ttk.Entry(config_frame, textvariable=self.grok2api_remote_base_var, width=30)
|
||
self.grok2api_remote_base_entry.grid(row=15, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="grok2api 远端 app_key:").grid(row=16, column=0, sticky=tk.W)
|
||
self.grok2api_remote_key_var = tk.StringVar(value=str(config.get("grok2api_remote_app_key", "")))
|
||
self.grok2api_remote_key_entry = ttk.Entry(config_frame, textvariable=self.grok2api_remote_key_var, width=30)
|
||
self.grok2api_remote_key_entry.grid(row=16, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
ttk.Label(config_frame, text="默认域名(defaultDomains):").grid(row=17, column=0, sticky=tk.W)
|
||
self.default_domains_var = tk.StringVar(value=str(config.get("defaultDomains", "")))
|
||
self.default_domains_entry = ttk.Entry(config_frame, textvariable=self.default_domains_var, width=30)
|
||
self.default_domains_entry.grid(row=17, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
|
||
ttk.Label(config_frame, text="Hotmail账号文件:").grid(row=18, column=0, sticky=tk.W)
|
||
self.hotmail_accounts_file_var = tk.StringVar(value=str(config.get("hotmail_accounts_file", "mail_credentials.txt")))
|
||
self.hotmail_accounts_file_entry = ttk.Entry(config_frame, textvariable=self.hotmail_accounts_file_var, width=30)
|
||
self.hotmail_accounts_file_entry.grid(row=18, column=1, columnspan=3, sticky=tk.W, padx=5)
|
||
btn_frame = ttk.Frame(main_frame)
|
||
btn_frame.pack(fill=tk.X, pady=10)
|
||
self.start_btn = ttk.Button(btn_frame, text="开始注册", command=self.start_registration)
|
||
self.start_btn.pack(side=tk.LEFT, padx=5)
|
||
self.stop_btn = ttk.Button(btn_frame, text="停止", command=self.stop_registration, state=tk.DISABLED)
|
||
self.stop_btn.pack(side=tk.LEFT, padx=5)
|
||
self.clear_btn = ttk.Button(btn_frame, text="清空日志", command=self.clear_log)
|
||
self.clear_btn.pack(side=tk.LEFT, padx=5)
|
||
self.help_btn = ttk.Button(btn_frame, text="教程", command=self.show_tutorial)
|
||
self.help_btn.pack(side=tk.LEFT, padx=5)
|
||
status_frame = ttk.Frame(main_frame)
|
||
status_frame.pack(fill=tk.X, pady=5)
|
||
self.status_var = tk.StringVar(value="就绪")
|
||
ttk.Label(status_frame, text="状态: ").pack(side=tk.LEFT)
|
||
self.status_label = ttk.Label(status_frame, textvariable=self.status_var, foreground="green")
|
||
self.status_label.pack(side=tk.LEFT)
|
||
self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0")
|
||
ttk.Label(status_frame, textvariable=self.stats_var).pack(side=tk.RIGHT)
|
||
log_frame = ttk.LabelFrame(main_frame, text="日志", padding=5)
|
||
log_frame.pack(fill=tk.BOTH, expand=True, pady=5)
|
||
self.log_text = scrolledtext.ScrolledText(log_frame, height=15, width=60)
|
||
self.log_text.pack(fill=tk.BOTH, expand=True)
|
||
|
||
def log(self, message):
|
||
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||
# 仅当用户当前就在底部时自动跟随,避免手动上滑后被强制拉回底部
|
||
yview = self.log_text.yview()
|
||
at_bottom = bool(yview) and yview[1] >= 0.999
|
||
self.log_text.insert(tk.END, f"[{timestamp}] {message}\n")
|
||
if at_bottom:
|
||
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 _maybe_show_tutorial_on_start(self):
|
||
if bool(config.get("show_tutorial_on_start", True)):
|
||
self.show_tutorial()
|
||
|
||
def _tutorial_text(self):
|
||
return """欢迎使用 Grok 注册机。建议按下面顺序填写(从最关键到可选):
|
||
|
||
【第一步:先确定邮箱后端信息从哪里来】
|
||
如果你使用 cloudflare 模式(你当前主要是这套),先去你的临时邮箱服务配置接口查信息:
|
||
- 常见接口: /open_api/settings、/api/settings、/health_check
|
||
- 重点字段:
|
||
- api_base(对应本工具的 Cloudflare API Base)
|
||
- domains / defaultDomains(可用域名)
|
||
- needAuth(是否需要鉴权)
|
||
- admin_password 或 api_key(需要鉴权时使用)
|
||
- provider.type(应为 cloudflare_temp_email)
|
||
|
||
【第二步:先填最小可运行配置】
|
||
1) 邮箱服务商
|
||
- duckmail: 需要 DuckMail API Key
|
||
- yyds: 需要 YYDS API Key 或 JWT
|
||
- cloudflare: 需要 Cloudflare API Base(cloudflare_temp_email 临时邮箱)
|
||
- cloudmail: 需要 CloudMail URL + 密码 + defaultDomains(maillab/cloud-mail 完整邮箱)
|
||
- hotmail/outlookmail: 需要 Hotmail账号文件,格式为 邮箱----密码----ClientID----Token
|
||
|
||
2) Cloudflare API Base(cloudflare 模式必填)
|
||
- 示例: https://xxxx.pages.dev
|
||
- 填写规则: 与 settings 接口中的 api_base 保持一致
|
||
|
||
3) 默认域名(defaultDomains)
|
||
- 填写你要优先使用的域名
|
||
- 支持单域名或逗号分隔多域名轮换
|
||
- 示例: a.com,b.com
|
||
|
||
4) CF 路径(domains/accounts/token/messages)
|
||
- 必须与后端真实路由一致
|
||
- 常见新路径:
|
||
- /api/domains,/api/new_address,/api/token,/api/mails
|
||
- 常见旧路径:
|
||
- /domains,/accounts,/token,/messages
|
||
|
||
5) Cloudflare API Key / 鉴权模式
|
||
- needAuth=false: 通常鉴权模式选 none,key 可留空
|
||
- needAuth=true: 按后端要求填 key,并选择 bearer/x-api-key/query-key
|
||
|
||
6) CloudMail 模式配置(maillab/cloud-mail 部署)
|
||
- CloudMail URL: 你的 Worker 地址,如 https://mail.xxx.workers.dev
|
||
- CloudMail 管理员邮箱: 管理员账号,如 admin@yourdomain.com
|
||
- CloudMail 管理员密码: 管理员密码(用于获取公开 API token 查询邮件)
|
||
- defaultDomains: 必须填写可用域名,如 yourdomain.com
|
||
- 前提: CloudMail 管理面板需关闭注册验证码(Turnstile),或确保注册接口可用
|
||
- 邮件获取: 通过 /api/public/emailList 公开接口查询,自动刷新 token
|
||
|
||
7) Hotmail/Outlook 模式配置
|
||
- Hotmail账号文件默认: mail_credentials.txt
|
||
- 每行格式: your@hotmail.com----mailPassword----client-id----refresh-token
|
||
- 默认先用原邮箱,后续使用随机 plus alias(如 name+k8s2p9qa@domain)
|
||
- 成功、失败、当前运行占用的 alias 都会去重,并通过 outlook.office365.com XOAUTH2 IMAP 收验证码
|
||
|
||
【第三步:并发与稳定性】
|
||
6) 注册数量
|
||
- 本次要注册的总账号数
|
||
|
||
7) 并发线程
|
||
- 建议先 3-6 稳定后再升到 10
|
||
|
||
8) 代理(可选)
|
||
- 不填=直连
|
||
- 示例: http://127.0.0.1:7890
|
||
- 代理不稳会影响验证码和注册稳定性
|
||
|
||
9) 注册后开启 NSFW
|
||
- 勾选后成功账号会自动调用接口开启对应设置
|
||
|
||
【第四步:grok2api 入池(可选)】
|
||
10) grok2api 本地自动入池
|
||
- 开启后把成功 sso 自动写入本地池
|
||
- 本地 token.json 填 grok2api 的 token.json 路径
|
||
|
||
11) grok2api 池名
|
||
- ssoBasic 或 ssoSuper
|
||
|
||
12) grok2api 远端自动入池
|
||
- 开启后调用远端管理接口自动加 token
|
||
- 远端 Base 示例: https://xxx/admin/api
|
||
- app_key 按远端服务配置填写
|
||
|
||
【最后:快速自检】
|
||
1) 先设置: 注册数量=1,并发线程=1
|
||
2) 点开始后看日志是否出现:
|
||
- 已创建邮箱: xxx@你的域名
|
||
- Cloudflare/CloudMail 本轮邮件数量: ...
|
||
- 从邮件中提取到验证码: ...
|
||
3) 若第一步就失败:
|
||
- cloudflare 模式: 检查 API Base / CF 路径 / 鉴权模式
|
||
- cloudmail 模式: 检查 URL / 密码 / defaultDomains / 注册接口是否可用
|
||
|
||
提示:
|
||
- 点“开始注册”会自动保存当前配置到 config.json。
|
||
- 如果关闭了启动教程,可随时点主界面的“教程”按钮重新打开。"""
|
||
|
||
def show_tutorial(self):
|
||
if self._tutorial_window is not None and self._tutorial_window.winfo_exists():
|
||
self._tutorial_window.lift()
|
||
self._tutorial_window.focus_force()
|
||
return
|
||
|
||
win = tk.Toplevel(self.root)
|
||
self._tutorial_window = win
|
||
win.title("使用教程")
|
||
win.geometry("760x620")
|
||
win.minsize(680, 520)
|
||
win.transient(self.root)
|
||
|
||
frame = ttk.Frame(win, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
txt = scrolledtext.ScrolledText(frame, wrap=tk.WORD, height=26)
|
||
txt.pack(fill=tk.BOTH, expand=True)
|
||
txt.insert("1.0", self._tutorial_text())
|
||
txt.config(state=tk.DISABLED)
|
||
|
||
footer = ttk.Frame(frame)
|
||
footer.pack(fill=tk.X, pady=(8, 0))
|
||
|
||
dont_show_var = tk.BooleanVar(value=not bool(config.get("show_tutorial_on_start", True)))
|
||
chk = ttk.Checkbutton(
|
||
footer,
|
||
text="以后不再自动显示本教程",
|
||
variable=dont_show_var,
|
||
)
|
||
chk.pack(side=tk.LEFT)
|
||
|
||
def on_close():
|
||
config["show_tutorial_on_start"] = not bool(dont_show_var.get())
|
||
save_config()
|
||
try:
|
||
win.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
close_btn = ttk.Button(footer, text="关闭", command=on_close)
|
||
close_btn.pack(side=tk.RIGHT, padx=5)
|
||
win.protocol("WM_DELETE_WINDOW", on_close)
|
||
|
||
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["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 "bearer"
|
||
config["cloudmail_url"] = self.cloudmail_url_var.get().strip()
|
||
config["cloudmail_admin_email"] = self.cloudmail_admin_email_var.get().strip()
|
||
config["cloudmail_password"] = self.cloudmail_password_var.get().strip()
|
||
config["grok2api_auto_add_local"] = bool(self.grok2api_local_auto_var.get())
|
||
config["grok2api_local_token_file"] = self.grok2api_local_file_var.get().strip()
|
||
config["grok2api_pool_name"] = self.grok2api_pool_name_var.get().strip() or "ssoBasic"
|
||
config["grok2api_auto_add_remote"] = bool(self.grok2api_remote_auto_var.get())
|
||
config["grok2api_remote_base"] = self.grok2api_remote_base_var.get().strip()
|
||
config["grok2api_remote_app_key"] = self.grok2api_remote_key_var.get().strip()
|
||
config["defaultDomains"] = self.default_domains_var.get().strip()
|
||
config["hotmail_accounts_file"] = self.hotmail_accounts_file_var.get().strip() or "mail_credentials.txt"
|
||
try:
|
||
config["register_threads"] = max(1, min(10, int(self.thread_var.get())))
|
||
except Exception:
|
||
config["register_threads"] = 1
|
||
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
|
||
if config["email_provider"] == "cloudmail":
|
||
if not config.get("cloudmail_url"):
|
||
self.log("[!] CloudMail 模式需要先填写 CloudMail URL")
|
||
return
|
||
if not config.get("cloudmail_admin_email"):
|
||
self.log("[!] CloudMail 模式需要先填写 CloudMail 管理员邮箱")
|
||
return
|
||
if not config.get("cloudmail_password"):
|
||
self.log("[!] CloudMail 模式需要先填写 CloudMail 管理员密码")
|
||
return
|
||
if config["email_provider"] in ("hotmail", "outlook", "outlookmail", "microsoft"):
|
||
hotmail_path = get_hotmail_accounts_file()
|
||
if not os.path.exists(hotmail_path):
|
||
self.log(f"[!] Hotmail/Outlook 模式账号文件不存在: {hotmail_path}")
|
||
return
|
||
try:
|
||
count = int(self.count_var.get())
|
||
except Exception:
|
||
self.log("[!] 注册数量无效")
|
||
return
|
||
self.stop_requested = False
|
||
self.success_count = 0
|
||
self.fail_count = 0
|
||
self.results = []
|
||
now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
self.accounts_output_file = os.path.join(
|
||
os.path.dirname(__file__), f"accounts_{now}.txt"
|
||
)
|
||
self.update_stats()
|
||
self._set_running_ui(True)
|
||
worker_count = max(1, min(config.get("register_threads", 1), count))
|
||
self.log(f"[*] 配置已保存,开始执行。目标数量: {count},并发线程: {worker_count}")
|
||
self.log(f"[*] 成功账号将实时保存到: {self.accounts_output_file}")
|
||
threading.Thread(
|
||
target=self.run_registration,
|
||
args=(count, worker_count),
|
||
daemon=True,
|
||
).start()
|
||
|
||
def stop_registration(self):
|
||
self.stop_requested = True
|
||
self.log("[!] 用户停止注册")
|
||
|
||
def _run_single_registration(self, idx, total, logf):
|
||
email = ""
|
||
dev_token = ""
|
||
code = ""
|
||
mail_ok = False
|
||
max_mail_retry = 3
|
||
for mail_try in range(1, max_mail_retry + 1):
|
||
logf(f"[*] 1. 打开注册页 (尝试 {mail_try}/{max_mail_retry})")
|
||
open_signup_page(log_callback=logf, cancel_callback=self.should_stop)
|
||
logf("[*] 2. 创建邮箱并提交")
|
||
email, dev_token = fill_email_and_submit(log_callback=logf, cancel_callback=self.should_stop)
|
||
logf(f"[*] 邮箱: {email}")
|
||
if get_email_provider() not in ("hotmail", "outlook", "outlookmail", "microsoft"):
|
||
try:
|
||
with open(os.path.join(os.path.dirname(__file__), "created_mailboxes.txt"), "a", encoding="utf-8") as f:
|
||
f.write(f"{email}\t{dev_token}\n")
|
||
except Exception:
|
||
pass
|
||
logf("[*] 3. 拉取验证码")
|
||
try:
|
||
code = fill_code_and_submit(email, dev_token, log_callback=logf, cancel_callback=self.should_stop)
|
||
mail_ok = True
|
||
break
|
||
except Exception as mail_exc:
|
||
msg = str(mail_exc)
|
||
if email:
|
||
try:
|
||
mark_error(email, reason=msg[:120])
|
||
except Exception:
|
||
pass
|
||
if ("未收到验证码" in msg or "验证码" in msg) and mail_try < max_mail_retry:
|
||
logf(f"[!] 本邮箱未取到验证码,自动更换新邮箱重试: {msg}")
|
||
restart_browser(log_callback=logf)
|
||
sleep_with_cancel(1, self.should_stop)
|
||
continue
|
||
raise
|
||
if not mail_ok:
|
||
raise Exception("验证码阶段失败,已达到最大重试次数")
|
||
logf(f"[*] 验证码: {code}")
|
||
try:
|
||
logf("[*] 4. 填写资料")
|
||
profile = fill_profile_and_submit(log_callback=logf, cancel_callback=self.should_stop)
|
||
logf(f"[*] 资料已填: {profile.get('given_name')} {profile.get('family_name')}")
|
||
logf("[*] 5. 等待 sso cookie")
|
||
sso = wait_for_sso_cookie(log_callback=logf, cancel_callback=self.should_stop)
|
||
except Exception as flow_exc:
|
||
if email:
|
||
try:
|
||
mark_error(email, reason=str(flow_exc)[:120])
|
||
except Exception:
|
||
pass
|
||
raise
|
||
password = profile.get("password", "") or ""
|
||
result_record = {"email": email, "sso": sso, "profile": profile}
|
||
with self.stats_lock:
|
||
self.results.append(result_record)
|
||
self.success_count += 1
|
||
line = f"{email}----{password}----{sso}\n"
|
||
try:
|
||
with open(self.accounts_output_file, "a", encoding="utf-8") as f:
|
||
f.write(line)
|
||
except Exception as file_exc:
|
||
logf(f"[Debug] 保存账号文件失败: {file_exc}")
|
||
try:
|
||
mark_used(email, password)
|
||
except Exception:
|
||
pass
|
||
logf(f"[+] 注册成功: {email}")
|
||
add_token_to_grok2api_pools(sso, email=email, log_callback=logf)
|
||
try:
|
||
page = _get_page()
|
||
except Exception:
|
||
page = None
|
||
logf("[cpa] 开始自动导出 CPA xai 认证文件")
|
||
cpa_result = export_cpa_after_success(
|
||
email,
|
||
password,
|
||
sso,
|
||
page=page,
|
||
log_callback=logf,
|
||
)
|
||
result_record["cpa"] = cpa_result
|
||
|
||
def _worker_loop(self, worker_id, total, task_queue):
|
||
prefix = f"[T{worker_id}]"
|
||
logf = lambda m: self.log(f"{prefix} {m}")
|
||
try:
|
||
start_browser(log_callback=logf)
|
||
logf("[*] 浏览器已启动")
|
||
while not self.should_stop():
|
||
try:
|
||
idx = task_queue.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
logf(f"--- 开始第 {idx}/{total} 个账号 ---")
|
||
try:
|
||
self._run_single_registration(idx, total, logf)
|
||
except RegistrationCancelled:
|
||
logf("[!] 注册被用户停止")
|
||
break
|
||
except Exception as exc:
|
||
with self.stats_lock:
|
||
self.fail_count += 1
|
||
logf(f"[-] 注册失败: {exc}")
|
||
finally:
|
||
self.update_stats()
|
||
if self.should_stop():
|
||
break
|
||
restart_browser(log_callback=logf)
|
||
sleep_with_cancel(1, self.should_stop)
|
||
except Exception as exc:
|
||
logf(f"[!] 线程异常: {exc}")
|
||
finally:
|
||
stop_browser()
|
||
|
||
def run_registration(self, count, worker_count):
|
||
task_queue = queue.Queue()
|
||
for i in range(1, count + 1):
|
||
task_queue.put(i)
|
||
workers = []
|
||
try:
|
||
start_interval = float(config.get("thread_start_interval", 0.8))
|
||
except Exception:
|
||
start_interval = 0.8
|
||
if start_interval < 0:
|
||
start_interval = 0.0
|
||
for wid in range(1, worker_count + 1):
|
||
t = threading.Thread(target=self._worker_loop, args=(wid, count, task_queue), daemon=True)
|
||
workers.append(t)
|
||
t.start()
|
||
if wid < worker_count and start_interval > 0:
|
||
sleep_with_cancel(start_interval, self.should_stop)
|
||
for t in workers:
|
||
t.join()
|
||
self._set_running_ui(False)
|
||
self.log("[*] 任务结束")
|
||
|
||
def main():
|
||
root = tk.Tk()
|
||
app = GrokRegisterGUI(root)
|
||
root.mainloop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|