User does not want web/browser casting; HTTP-only unless cpa_allow_browser_fallback is explicitly re-enabled.
4030 lines
154 KiB
Python
4030 lines
154 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Grok 注册机 - TTK GUI 版本
|
||
整合 DrissionPage_example.py, openai_register.py, batch_open_nsfw.py
|
||
"""
|
||
|
||
import threading
|
||
import datetime
|
||
import time
|
||
import os
|
||
import sys
|
||
import gc
|
||
import secrets
|
||
import struct
|
||
import random
|
||
import re
|
||
import string
|
||
import json
|
||
|
||
from DrissionPage import Chromium, ChromiumOptions
|
||
from DrissionPage.errors import PageDisconnectedError
|
||
from curl_cffi import requests
|
||
import psutil
|
||
|
||
|
||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||
MEMORY_CLEANUP_INTERVAL = 5
|
||
|
||
DEFAULT_CONFIG = {
|
||
"duckmail_api_key": "",
|
||
"cloudflare_api_base": "",
|
||
"cloudflare_api_key": "",
|
||
"cloudflare_auth_mode": "none",
|
||
"cloudflare_path_domains": "/api/domains",
|
||
"cloudflare_path_accounts": "/api/new_address",
|
||
"cloudflare_path_token": "/api/token",
|
||
"cloudflare_path_messages": "/api/mails",
|
||
# ===== 自建临时邮箱 mail.bblbb.com(任意 local-part 可收,Header X-Api-Key)=====
|
||
# 注意:若本地已有 config.json,其中的 email_provider / defaultDomains 会覆盖此处默认值。
|
||
"email_provider": "bblbb",
|
||
"defaultDomains": "mail.bblbb.com",
|
||
"bblbb_api_base": "https://mail.bblbb.com",
|
||
"bblbb_api_key": "",
|
||
"bblbb_domain": "mail.bblbb.com",
|
||
"bblbb_poll_interval": 2.0,
|
||
"proxy": "",
|
||
# ===== 代理池(注册浏览器 + CPA 铸造共用线程绑定出口)=====
|
||
"proxy_pool_enabled": False, # True=启用代理池;False=仅用 proxy 单代理/直连
|
||
"proxy_managed": False, # True=只用 proxy_alive.json(accounts.x.ai 测通过的)
|
||
"proxy_db": "./proxy_alive.json", # 测通库存
|
||
"proxy_pool": [], # 静态列表兜底
|
||
"proxy_pool_file": "./proxies.txt", # harvest 会导出;也可手写
|
||
"proxy_pool_mode": "weighted", # weighted | round_robin | random
|
||
"proxy_rotate_each_account": True, # 每个账号换一个出口(重启浏览器)
|
||
"proxy_mail_direct": True, # 邮箱 API 直连,不走代理(temp-email/Worker 更稳)
|
||
# 公开源采集 / 巡检(python proxy_manager.py harvest|check|loop)
|
||
"proxy_source_pick_k": 3, # 每轮按权重抽几个源
|
||
"proxy_harvest_per_source": 150,
|
||
"proxy_harvest_total": 400,
|
||
"proxy_probe_workers": 40,
|
||
"proxy_probe_timeout": 12,
|
||
"proxy_max_alive": 200,
|
||
"proxy_min_alive": 15,
|
||
"proxy_harvest_interval_sec": 600,
|
||
"proxy_check_interval_sec": 300,
|
||
"proxy_source_disable_after": 3, # 源连续 0 产出/失败 N 次禁用
|
||
"enable_nsfw": True,
|
||
"register_count": 1,
|
||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||
# ===== CPA (CLIProxyAPI) 导出 / 免费 Grok 4.5(OIDC)=====
|
||
# 注册成功后用 SSO cookie 走 Authorization Code + PKCE,强制 referrer=grok-build,
|
||
# 写出 CLIProxyAPI 的 xai-<email>.json,并可推送到远端。免费号用 cli-chat-proxy。
|
||
# 旧设备码铸造的 token 缺 referrer,cli-chat-proxy 已不可用。
|
||
"cpa_export_enabled": True, # 注册成功后是否铸造 OIDC 并写 CPA 认证文件
|
||
"cpa_auth_dir": "./cpa_auths", # 本地写盘目录(xai-<email>.json)
|
||
"cpa_base_url": "https://cli-chat-proxy.grok.com/v1", # 写进 auth 的 base_url;付费用 https://api.x.ai/v1
|
||
# 远端推送到 CLIProxyAPI(POST /v0/management/auth-files)
|
||
"cpa_push_enabled": True, # 是否推送到远端 CLIProxyAPI
|
||
"cpa_remote_base": "https://cpa.nopj.cn", # 远端 CLIProxyAPI 地址
|
||
"cpa_remote_secret": "DNznuRVoBhDT2SpSAyWw", # 远端管理密钥(CLIProxyAPI remote-management.secret-key)
|
||
"cpa_remote_verify_tls": True, # https 远端是否校验证书
|
||
"cpa_push_proxy": "", # 推送用代理;空=直连(不走 mint 代理)
|
||
"cpa_push_required": False, # True=推送失败则整次注册算失败
|
||
# OIDC:优先 SSO→Authorization Code + referrer=grok-build
|
||
"cpa_prefer_sso_oauth": True, # True=用 sso cookie 走 PKCE(必须带 referrer)
|
||
"cpa_require_referrer": True, # True=access_token 无 referrer=grok-build 则失败
|
||
"cpa_prefer_browser_mint": False, # True=有 page 时先浏览器(慢);默认关
|
||
"cpa_allow_browser_fallback": False, # True=HTTP 失败再用浏览器 PKCE;默认关(不要网页铸造)
|
||
"cpa_browser_mint_timeout_sec": 90, # 浏览器 PKCE 最长等待(仅 fallback 开启时)
|
||
"cpa_allow_device_fallback": False, # True=SSO 失败时回退设备码(通常不可用)
|
||
# OIDC 铸造代理/超时
|
||
"mint_proxy": "", # 铸造专用代理;空=复用 proxy
|
||
"mint_timeout_sec": 300, # 单账号铸造超时(秒)
|
||
"mint_required": False, # True=铸造失败则整次注册算失败
|
||
# 以下优化默认关闭 = 与最初脚本一致;实测三者都不影响 Turnstile 通过,可按需开启。
|
||
# (注:Blink 层禁图会导致验证过不去,已弃用;省图片带宽改由 block_media_fonts 承担)
|
||
"block_media_fonts": False, # 网络层拦截图片/字体/媒体省带宽(省带宽主力,不影响验证)
|
||
"hide_window": False, # 把浏览器窗口移到屏幕外隐藏
|
||
"stealth_patch": False, # 内联 turnstilePatch 全局注入
|
||
# ===== CF Email Routing Worker 取码(非 temp-email)=====
|
||
# 域名在 Email Routing catch-all → Worker KV → GET /code
|
||
"cf_worker_domains": "", # 走 Worker 的域名,逗号分隔,如 zhangyunuo.net
|
||
"gmail_worker_enabled": False,
|
||
"gmail_worker_url": "",
|
||
"gmail_worker_api_key": "",
|
||
"gmail_worker_timeout_sec": 20,
|
||
"gmail_worker_poll_interval": 0.4,
|
||
"gmail_imap_fallback": False, # Worker 未命中是否回退 Gmail IMAP
|
||
"gmail_imap_user": "",
|
||
"gmail_imap_password": "",
|
||
"gmail_imap_host": "imap.gmail.com",
|
||
"gmail_imap_port": 993,
|
||
}
|
||
|
||
config = DEFAULT_CONFIG.copy()
|
||
_cf_domain_index = 0
|
||
_cf_domain_lock = threading.Lock()
|
||
|
||
|
||
class RegistrationCancelled(Exception):
|
||
pass
|
||
|
||
|
||
class AccountRetryNeeded(Exception):
|
||
pass
|
||
|
||
|
||
def load_config():
|
||
global config
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||
loaded = json.load(f)
|
||
config = {**DEFAULT_CONFIG, **loaded}
|
||
except Exception:
|
||
config = DEFAULT_CONFIG.copy()
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.configure_from_config(config)
|
||
except Exception:
|
||
pass
|
||
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(for_mail=False):
|
||
"""返回 curl_cffi/requests 用的 proxies dict。
|
||
|
||
for_mail=True 且 proxy_mail_direct 时强制直连(邮箱 API 不需要换 IP)。
|
||
否则优先线程绑定代理(代理池),再回退 config.proxy。
|
||
"""
|
||
try:
|
||
import proxy_pool
|
||
if for_mail and proxy_pool.mail_direct():
|
||
return {}
|
||
p = proxy_pool.resolve_for_http()
|
||
if p:
|
||
return {"http": p, "https": p}
|
||
except Exception:
|
||
pass
|
||
proxy = str(config.get("proxy", "") or "").strip()
|
||
if proxy:
|
||
return {"http": proxy, "https": proxy}
|
||
return {}
|
||
|
||
|
||
def current_proxy_label():
|
||
try:
|
||
import proxy_pool
|
||
p = proxy_pool.get_thread_proxy() or str(config.get("proxy") or "").strip()
|
||
return proxy_pool.proxy_log_label(p)
|
||
except Exception:
|
||
p = str(config.get("proxy") or "").strip()
|
||
return p or "(direct)"
|
||
|
||
|
||
def get_duckmail_api_key():
|
||
return config.get("duckmail_api_key", "")
|
||
|
||
|
||
def get_cloudflare_api_base():
|
||
return str(config.get("cloudflare_api_base", "") or "").rstrip("/")
|
||
|
||
|
||
def get_cloudflare_api_key():
|
||
return config.get("cloudflare_api_key", "")
|
||
|
||
|
||
def get_cloudflare_auth_mode():
|
||
return str(config.get("cloudflare_auth_mode", "none") or "none").lower()
|
||
|
||
|
||
def get_cloudflare_path(key, default_path):
|
||
raw = str(config.get(key, default_path) or default_path).strip()
|
||
if not raw.startswith("/"):
|
||
raw = "/" + raw
|
||
return raw
|
||
|
||
|
||
def cloudflare_build_headers(content_type=False):
|
||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key:
|
||
if mode == "x-api-key":
|
||
headers["X-API-Key"] = key
|
||
elif mode == "x-admin-auth":
|
||
headers["x-admin-auth"] = key
|
||
elif mode != "none":
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
return headers
|
||
|
||
|
||
def cloudflare_apply_auth_params(params=None):
|
||
merged = dict(params or {})
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key and mode == "query-key":
|
||
merged["key"] = key
|
||
return merged
|
||
|
||
|
||
def cloudflare_default_domains():
|
||
"""解析 config.defaultDomains(逗号分隔)为域名列表。"""
|
||
return [x.strip() for x in str(config.get("defaultDomains", "") or "").split(",") if x.strip()]
|
||
|
||
|
||
def cloudflare_next_default_domain():
|
||
"""按配置轮换选择 Cloudflare 临时邮箱域名。"""
|
||
global _cf_domain_index
|
||
domains = cloudflare_default_domains()
|
||
if not domains:
|
||
return ""
|
||
with _cf_domain_lock:
|
||
domain = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
return domain
|
||
|
||
|
||
def cloudflare_is_admin_create_path(path):
|
||
"""判断当前创建邮箱路径是否为 cloudflare_temp_email 管理员创建接口。"""
|
||
return str(path or "").rstrip("/").lower() == "/admin/new_address"
|
||
|
||
|
||
def _pick_list_payload(data):
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict):
|
||
if isinstance(data.get("results"), list):
|
||
return data.get("results")
|
||
if isinstance(data.get("hydra:member"), list):
|
||
return data.get("hydra:member")
|
||
if isinstance(data.get("data"), list):
|
||
return data.get("data")
|
||
if isinstance(data.get("messages"), list):
|
||
return data.get("messages")
|
||
if isinstance(data.get("data"), dict):
|
||
nested = data.get("data")
|
||
if isinstance(nested.get("messages"), list):
|
||
return nested.get("messages")
|
||
return []
|
||
|
||
|
||
def cloudflare_create_temp_address(api_base, preferred_domain=None):
|
||
"""适配 cloudflare_temp_email 新建地址接口并兼容 admin 创建模式。
|
||
|
||
多域名时按 defaultDomains 顺序尝试;某个域名 Invalid domain 时自动换下一个。
|
||
preferred_domain: 强制优先该域名(用于双通道轮换,避免改全局 config)。
|
||
会自动跳过 cf_worker_domains(那些域名不走 temp-email)。
|
||
"""
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/api/new_address")
|
||
url = f"{api_base}{path}"
|
||
worker_skip = cf_worker_domain_set()
|
||
domains = cloudflare_default_domains()
|
||
domains = [d for d in domains if d and d.lower() not in worker_skip]
|
||
if preferred_domain:
|
||
pref = preferred_domain.strip()
|
||
if pref and pref.lower() not in worker_skip:
|
||
domains = [pref] + [d for d in domains if d.lower() != pref.lower()]
|
||
if not domains:
|
||
domains = [""]
|
||
is_admin_create = cloudflare_is_admin_create_path(path)
|
||
last_err = None
|
||
if preferred_domain:
|
||
# 外层已轮换选域,这里不再推进全局下标
|
||
ordered = domains
|
||
else:
|
||
# 从轮换下标开始,保证多域名时负载分散,且失败可回退
|
||
start = 0
|
||
with _cf_domain_lock:
|
||
start = _cf_domain_index % max(len(domains), 1)
|
||
_cf_domain_index += 1
|
||
ordered = domains[start:] + domains[:start]
|
||
for domain in ordered:
|
||
if is_admin_create:
|
||
payload = {"name": generate_username(10), "enablePrefix": True}
|
||
if domain:
|
||
payload["domain"] = domain
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
else:
|
||
payload = {}
|
||
if domain:
|
||
payload["domain"] = domain
|
||
headers = {"Content-Type": "application/json"}
|
||
try:
|
||
resp = http_post(url, json=payload, headers=headers)
|
||
body_text = resp.text or ""
|
||
if resp.status_code >= 400:
|
||
last_err = f"HTTP {resp.status_code}: {body_text[:200]}"
|
||
# 域名未绑定到 temp-email 时常见错误,换下一个域名
|
||
low = body_text.lower()
|
||
if "invalid domain" in low or "domain" in low and "invalid" in low:
|
||
continue
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare {path} 返回非JSON: {body_text[:300]}")
|
||
address = data.get("address")
|
||
jwt = data.get("jwt")
|
||
if not address or not jwt:
|
||
last_err = f"缺少 address/jwt: {data}"
|
||
continue
|
||
return address, jwt
|
||
except Exception as exc:
|
||
last_err = str(exc)
|
||
low = last_err.lower()
|
||
if "invalid domain" in low:
|
||
continue
|
||
# 非域名类错误不继续盲试
|
||
if domain == ordered[-1]:
|
||
break
|
||
if "domain" in low:
|
||
continue
|
||
raise
|
||
raise Exception(
|
||
f"Cloudflare 创建邮箱失败(已尝试域名 {', '.join([d or '(默认)' for d in ordered])}): {last_err}"
|
||
)
|
||
|
||
|
||
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 export_cpa_after_register(email, password, session=None, sso="", log_callback=None):
|
||
"""注册成功后铸造 Grok Build OIDC,写出 CPA (CLIProxyAPI) 的 xai-<email>.json,
|
||
并按配置推送到远端 CLIProxyAPI 导入。
|
||
|
||
默认优先用 SSO cookie 走 Authorization Code + PKCE(referrer=grok-build),
|
||
不再依赖设备码浏览器确认。本地写到 config['cpa_auth_dir'],
|
||
远端推送到 config['cpa_remote_base'] 的 /v0/management/auth-files。
|
||
|
||
铸造代理优先复用当前线程绑定(与注册浏览器同出口),避免直连被 abusive 拦。
|
||
"""
|
||
log = log_callback or cli_log
|
||
if not config.get("cpa_export_enabled", True):
|
||
log("[cpa] 已关闭导出,跳过")
|
||
return {"ok": False, "skipped": True, "reason": "disabled"}
|
||
|
||
try:
|
||
import cpa_export
|
||
except Exception as exc:
|
||
log(f"[cpa] 导入 cpa_export 失败: {exc}")
|
||
if config.get("mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": f"import: {exc}"}
|
||
|
||
# 把线程代理 / session 代理写进 mint_proxy,覆盖全局直连
|
||
cfg = dict(config)
|
||
try:
|
||
import proxy_pool
|
||
bound = ""
|
||
if session is not None:
|
||
bound = getattr(session, "proxy", None) or ""
|
||
bound = bound or proxy_pool.get_thread_proxy()
|
||
if bound:
|
||
cfg["mint_proxy"] = bound
|
||
cfg["proxy"] = bound
|
||
proxy_pool.set_thread_proxy(bound)
|
||
log(f"[cpa] mint 代理={proxy_pool.proxy_log_label(bound)}")
|
||
else:
|
||
log(f"[cpa] mint 代理={current_proxy_label()}")
|
||
except Exception:
|
||
pass
|
||
|
||
page = getattr(session, "page", None) if session is not None else None
|
||
try:
|
||
result = cpa_export.export_cpa_for_account(
|
||
email,
|
||
password,
|
||
page=page,
|
||
sso=sso or "",
|
||
config=cfg,
|
||
log_callback=log,
|
||
)
|
||
except Exception as exc:
|
||
log(f"[cpa] 导出异常: {exc}")
|
||
if config.get("mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": str(exc)}
|
||
|
||
if result.get("ok"):
|
||
tail = " 并已推送远端" if result.get("pushed") else ""
|
||
ref = result.get("referrer") or ""
|
||
ref_s = f" referrer={ref}" if ref else ""
|
||
log(f"[+] CPA 认证已写出: {result.get('path')}{ref_s}{tail}")
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.report_success()
|
||
except Exception:
|
||
pass
|
||
elif result.get("skipped"):
|
||
log(f"[cpa] 跳过: {result.get('reason')}")
|
||
else:
|
||
err = str(result.get("error") or result)
|
||
log(f"[!] CPA 导出未成功: {err}")
|
||
# OAuth 403 abusive / 代理隧道失败 → 当前出口作废
|
||
low = err.lower()
|
||
if any(k in low for k in ("abusive", "403", "blocked", "proxy", "tunnel", "timeout")):
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.report_failure(reason=f"cpa mint: {err[:160]}")
|
||
except Exception:
|
||
pass
|
||
return result
|
||
|
||
|
||
def cleanup_mint_browsers():
|
||
"""关闭铸造 worker 复用的独立 Chromium(线程退出时调用)。"""
|
||
try:
|
||
from oidc_mint import shutdown_mint_browsers
|
||
shutdown_mint_browsers()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _kill_proc_tree(pid, timeout=3):
|
||
"""强杀指定进程及其所有子进程(浏览器 quit 不干净时兜底)。"""
|
||
if not pid:
|
||
return
|
||
try:
|
||
parent = psutil.Process(pid)
|
||
except Exception:
|
||
return
|
||
procs = []
|
||
try:
|
||
procs = parent.children(recursive=True)
|
||
except Exception:
|
||
pass
|
||
procs.append(parent)
|
||
for p in procs:
|
||
try:
|
||
p.kill()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
psutil.wait_procs(procs, timeout=timeout)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _kill_chrome_session(pid=None, port=None):
|
||
"""精准杀掉某个浏览器会话:优先按调试端口定位真正的 chrome 主进程,再连启动器 pid 一起杀进程树。"""
|
||
targets = set()
|
||
if port:
|
||
marker = f"--remote-debugging-port={port}"
|
||
for p in psutil.process_iter(["name", "cmdline"]):
|
||
try:
|
||
if (p.info.get("name") or "").lower() == "chrome.exe" and marker in " ".join(p.info.get("cmdline") or []):
|
||
targets.add(p.pid)
|
||
except Exception:
|
||
continue
|
||
if pid:
|
||
targets.add(pid)
|
||
for tp in targets:
|
||
_kill_proc_tree(tp)
|
||
|
||
|
||
def cleanup_stray_chrome(log_callback=None, rounds=4):
|
||
"""退出兜底:杀掉带调试端口的孤儿 chrome 进程树 + GoogleUpdate 家族(多轮,清到杀不动为止)。
|
||
|
||
只杀命令行含 --remote-debugging-port 的 chrome(本程序拉起的),不碰你手动打开的 Chrome。
|
||
"""
|
||
log = log_callback or cli_log
|
||
updater = {"googleupdate.exe", "googlecrashhandler.exe",
|
||
"googlecrashhandler64.exe", "elevation_service.exe"}
|
||
total_chrome = 0
|
||
total_upd = 0
|
||
for _ in range(max(1, rounds)):
|
||
killed = 0
|
||
for p in psutil.process_iter(["name", "cmdline"]):
|
||
try:
|
||
name = (p.info.get("name") or "").lower()
|
||
if name in updater:
|
||
p.kill()
|
||
total_upd += 1
|
||
killed += 1
|
||
continue
|
||
if name == "chrome.exe":
|
||
cmd = " ".join(p.info.get("cmdline") or [])
|
||
if "--remote-debugging-port" in cmd:
|
||
for c in p.children(recursive=True):
|
||
try:
|
||
c.kill()
|
||
except Exception:
|
||
pass
|
||
p.kill()
|
||
total_chrome += 1
|
||
killed += 1
|
||
except Exception:
|
||
continue
|
||
if killed == 0:
|
||
break
|
||
time.sleep(0.5)
|
||
if total_chrome or total_upd:
|
||
log(f"[*] 清理残留:chrome {total_chrome} 组,GoogleUpdate {total_upd} 个")
|
||
|
||
|
||
def create_browser_options(proxy=None):
|
||
"""创建 Chromium 启动参数。proxy 优先用入参,否则用线程绑定/配置。"""
|
||
options = ChromiumOptions()
|
||
options.auto_port()
|
||
options.set_timeouts(base=1)
|
||
# 关键:全新临时用户目录下,Chrome 138 会走「首次启动」流程,DrissionPage 取不到
|
||
# 标签页而在 Chromium() 启动处卡死。--no-first-run 跳过首启,--no-default-browser-check
|
||
# 屏蔽「设为默认浏览器」提示;二者都不影响 Turnstile 渲染。
|
||
options.set_argument("--no-first-run")
|
||
options.set_argument("--no-default-browser-check")
|
||
# 禁用后台网络(含更新检查),减少 Chrome 拉起 GoogleUpdate.exe 残留
|
||
options.set_argument("--disable-background-networking")
|
||
options.set_argument("--disable-component-update")
|
||
# 禁用后台标签/窗口节流,避免多窗口时后台浏览器被降频、响应变慢
|
||
options.set_argument("--disable-background-timer-throttling")
|
||
options.set_argument("--disable-backgrounding-occluded-windows")
|
||
options.set_argument("--disable-renderer-backgrounding")
|
||
# 固定窗口尺寸,避免 Turnstile iframe 在 0 尺寸窗口里不渲染
|
||
options.set_argument("--window-size=1280,900")
|
||
# 隐藏窗口(可选):仅移出屏幕。注意:部分环境下会让 Turnstile 永远 token=0
|
||
if config.get("hide_window", False):
|
||
options.set_argument("--window-position=-32000,-32000")
|
||
else:
|
||
options.set_argument("--window-position=40,40")
|
||
|
||
# 代理:线程绑定(代理池)> 入参 > config.proxy
|
||
use_proxy = ""
|
||
try:
|
||
import proxy_pool
|
||
if proxy is not None:
|
||
use_proxy = proxy_pool.normalize_proxy(proxy)
|
||
else:
|
||
use_proxy = proxy_pool.get_thread_proxy() or proxy_pool.normalize_proxy(
|
||
config.get("proxy") or ""
|
||
)
|
||
chrome_proxy = proxy_pool.proxy_for_chromium(use_proxy)
|
||
if chrome_proxy:
|
||
options.set_argument(f"--proxy-server={chrome_proxy}")
|
||
if proxy_pool.proxy_has_auth(use_proxy):
|
||
# Chromium 不支持 URL 内嵌账号密码;需本地 auth 扩展或无认证代理
|
||
pass
|
||
except Exception:
|
||
raw = (proxy if proxy is not None else config.get("proxy") or "") or ""
|
||
raw = str(raw).strip()
|
||
if raw:
|
||
if "://" not in raw:
|
||
raw = f"http://{raw}"
|
||
options.set_argument(f"--proxy-server={raw}")
|
||
|
||
# 加载 turnstilePatch 扩展(伪造自动化鼠标事件指纹,帮过 Turnstile)
|
||
if os.path.exists(EXTENSION_PATH):
|
||
options.add_extension(EXTENSION_PATH)
|
||
return options
|
||
|
||
|
||
def ensure_browser_visible_for_turnstile(session, log_callback=None):
|
||
"""Turnstile 卡住时把窗口拉回屏幕并聚焦,避免隐藏窗口导致校验永不通过。"""
|
||
log = log_callback or (lambda *_: None)
|
||
page = getattr(session, "page", None)
|
||
browser = getattr(session, "browser", None)
|
||
if page is None and browser is None:
|
||
return
|
||
try:
|
||
target = page or browser
|
||
for attr_path in (
|
||
("set", "window", "max"),
|
||
("set", "window", "show"),
|
||
("set", "window", "to_front"),
|
||
("set", "window", "normal"),
|
||
):
|
||
try:
|
||
obj = target
|
||
for part in attr_path:
|
||
obj = getattr(obj, part)
|
||
if callable(obj):
|
||
obj()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if hasattr(target, "set") and hasattr(target.set, "window"):
|
||
try:
|
||
target.set.window.location(40, 40)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
target.set.window.size(1280, 900)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
if page is not None:
|
||
try:
|
||
page.run_js(
|
||
"try{window.focus();window.moveTo(40,40);window.resizeTo(1280,900);}catch(e){}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
log("[Debug] 已尝试把浏览器窗口拉回前台(Turnstile)")
|
||
except Exception as exc:
|
||
log(f"[Debug] ensure_browser_visible_for_turnstile: {exc}")
|
||
|
||
|
||
def _is_mail_api_url(url):
|
||
"""判断是否为邮箱服务 API(默认直连,不走注册代理池)。"""
|
||
u = str(url or "").lower()
|
||
if not u:
|
||
return False
|
||
markers = (
|
||
"duckmail",
|
||
"maliapi.215.im",
|
||
"/admin/new_address",
|
||
"/api/new_address",
|
||
"/api/mails",
|
||
"/api/mail/",
|
||
"/api/domains",
|
||
"/api/token",
|
||
"mail.kanxue.workers.dev",
|
||
"temp-email",
|
||
"workers.dev/code",
|
||
"grok-xai-mail-codes",
|
||
"mail.bblbb.com",
|
||
"/api/v1/mailbox/",
|
||
)
|
||
return any(m in u for m in markers)
|
||
|
||
|
||
def _build_request_kwargs(url=None, **kwargs):
|
||
request_kwargs = dict(kwargs)
|
||
proxies = request_kwargs.pop("proxies", None)
|
||
if proxies is None:
|
||
for_mail = _is_mail_api_url(url)
|
||
proxies = get_proxies(for_mail=for_mail)
|
||
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(url=url, **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
|
||
or "ProxyError" in err
|
||
or "CONNECT tunnel failed" in err
|
||
):
|
||
retry_kwargs = dict(kwargs)
|
||
retry_kwargs["proxies"] = {}
|
||
return requests.get(url, **_build_request_kwargs(url=url, **retry_kwargs))
|
||
raise
|
||
|
||
|
||
def http_post(url, **kwargs):
|
||
try:
|
||
return requests.post(url, **_build_request_kwargs(url=url, **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
|
||
or "ProxyError" in err
|
||
or "CONNECT tunnel failed" in err
|
||
):
|
||
retry_kwargs = dict(kwargs)
|
||
retry_kwargs["proxies"] = {}
|
||
return requests.post(url, **_build_request_kwargs(url=url, **retry_kwargs))
|
||
raise
|
||
|
||
|
||
def raise_if_cancelled(cancel_callback=None):
|
||
if cancel_callback and cancel_callback():
|
||
raise RegistrationCancelled("鐢ㄦ埛鍋滄娉ㄥ唽")
|
||
|
||
|
||
def sleep_with_cancel(seconds, cancel_callback=None):
|
||
deadline = time.time() + max(seconds, 0)
|
||
while True:
|
||
raise_if_cancelled(cancel_callback)
|
||
remaining = deadline - time.time()
|
||
if remaining <= 0:
|
||
return
|
||
time.sleep(min(0.2, remaining))
|
||
|
||
|
||
def get_domains(api_key=None):
|
||
headers = {}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
|
||
def create_account(address, password, api_key=None, expires_in=0):
|
||
headers = {"Content-Type": "application/json"}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
data = {"address": address, "password": password, "expiresIn": expires_in}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/accounts", json=data, headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def get_token(address, password):
|
||
data = {"address": address, "password": password}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/token", json=data)
|
||
resp.raise_for_status()
|
||
return resp.json().get("token")
|
||
|
||
|
||
def get_messages(token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
|
||
def get_message_detail(token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def cloudflare_get_domains(api_base, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=False)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_domains", "/domains")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return _pick_list_payload(resp.json())
|
||
|
||
|
||
def cloudflare_create_account(api_base, address, password, api_key=None, expires_in=0):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
payload = {"address": address, "password": password, "expiresIn": expires_in}
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/accounts")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_post(f"{api_base}{path}", json=payload, headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
def cloudflare_get_token(api_base, address, password, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_token", "/token")
|
||
resp = http_post(
|
||
f"{api_base}{path}",
|
||
json={"address": address, "password": password},
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
if data.get("token"):
|
||
return data.get("token")
|
||
if isinstance(data.get("data"), dict) and data["data"].get("token"):
|
||
return data["data"].get("token")
|
||
return None
|
||
|
||
|
||
def cloudflare_get_messages(api_base, token):
|
||
headers = {"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):
|
||
return generate_username(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"[Debug] YYDS 浠庨偖浠朵腑鎻愬彇鍒伴獙璇佺爜: {code}")
|
||
return code
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"YYDS 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
# 常见英文名/姓/词根,拼出更像真人的邮箱 local-part
|
||
_FIRST_NAMES = (
|
||
"james", "john", "robert", "michael", "david", "william", "richard", "joseph",
|
||
"thomas", "charles", "daniel", "matthew", "anthony", "mark", "steven", "paul",
|
||
"andrew", "joshua", "kevin", "brian", "george", "edward", "ronald", "timothy",
|
||
"jason", "jeffrey", "ryan", "jacob", "gary", "nicholas", "eric", "jonathan",
|
||
"stephen", "larry", "justin", "scott", "brandon", "benjamin", "samuel", "raymond",
|
||
"gregory", "frank", "alexander", "patrick", "jack", "dennis", "jerry", "tyler",
|
||
"aaron", "jose", "adam", "nathan", "henry", "douglas", "zachary", "peter",
|
||
"kyle", "noah", "ethan", "jeremy", "walter", "christian", "keith", "roger",
|
||
"mary", "patricia", "jennifer", "linda", "elizabeth", "barbara", "susan", "jessica",
|
||
"sarah", "karen", "lisa", "nancy", "betty", "margaret", "sandra", "ashley",
|
||
"kimberly", "emily", "donna", "michelle", "dorothy", "carol", "amanda", "melissa",
|
||
"deborah", "stephanie", "rebecca", "sharon", "laura", "cynthia", "kathleen", "amy",
|
||
"angela", "shirley", "anna", "brenda", "pamela", "emma", "nicole", "helen",
|
||
"samantha", "katherine", "christine", "debra", "rachel", "carolyn", "janet", "catherine",
|
||
"maria", "heather", "diane", "ruth", "julie", "olivia", "joyce", "virginia",
|
||
"victoria", "kelly", "lauren", "christina", "joan", "evelyn", "judith", "megan",
|
||
"cheryl", "andrea", "hannah", "jacqueline", "martha", "gloria", "teresa", "ann",
|
||
"sara", "madison", "frances", "kathryn", "janice", "jean", "abigail", "alice",
|
||
"judy", "sophia", "grace", "denise", "amber", "doris", "marilyn", "danielle",
|
||
"beverly", "isabella", "theresa", "diana", "natalie", "brittany", "charlotte", "marie",
|
||
"kayla", "alexis", "lori",
|
||
)
|
||
_LAST_NAMES = (
|
||
"smith", "johnson", "williams", "brown", "jones", "garcia", "miller", "davis",
|
||
"rodriguez", "martinez", "hernandez", "lopez", "gonzalez", "wilson", "anderson", "thomas",
|
||
"taylor", "moore", "jackson", "martin", "lee", "perez", "thompson", "white",
|
||
"harris", "sanchez", "clark", "ramirez", "lewis", "robinson", "walker", "young",
|
||
"allen", "king", "wright", "scott", "torres", "nguyen", "hill", "flores",
|
||
"green", "adams", "nelson", "baker", "hall", "rivera", "campbell", "mitchell",
|
||
"carter", "roberts", "gomez", "phillips", "evans", "turner", "diaz", "parker",
|
||
"cruz", "edwards", "collins", "reyes", "stewart", "morris", "morales", "murphy",
|
||
"cook", "rogers", "gutierrez", "ortiz", "morgan", "cooper", "peterson", "bailey",
|
||
"reed", "kelly", "howard", "ramos", "kim", "cox", "ward", "richardson",
|
||
"watson", "brooks", "chavez", "wood", "james", "bennett", "gray", "mendoza",
|
||
"ruiz", "hughes", "price", "alvarez", "castillo", "sanders", "patel", "myers",
|
||
"long", "ross", "foster", "jimenez",
|
||
)
|
||
_NAME_SEPARATORS = ("", ".", "_")
|
||
|
||
|
||
def generate_username(length=10):
|
||
"""生成更像正常英文用户的邮箱名:姓名/词根 + 数字结尾。
|
||
|
||
length 仅作兼容参数(旧调用传 10),实际长度由名字与数字后缀决定,约 8–18。
|
||
"""
|
||
first = secrets.choice(_FIRST_NAMES)
|
||
last = secrets.choice(_LAST_NAMES)
|
||
sep = secrets.choice(_NAME_SEPARATORS)
|
||
# 数字后缀:2–4 位更像真实用户(生日年份、学号尾号等)
|
||
digit_len = secrets.choice((2, 2, 3, 3, 3, 4))
|
||
if digit_len == 2:
|
||
digits = f"{secrets.randbelow(90) + 10}" # 10-99
|
||
elif digit_len == 3:
|
||
digits = f"{secrets.randbelow(900) + 100}" # 100-999
|
||
else:
|
||
# 常见 19xx / 20xx 年份感,或纯 4 位
|
||
if secrets.randbelow(100) < 45:
|
||
year = secrets.choice((19, 20))
|
||
yy = secrets.randbelow(26) if year == 20 else secrets.randbelow(90) + 10
|
||
digits = f"{year}{yy:02d}"
|
||
else:
|
||
digits = f"{secrets.randbelow(9000) + 1000}"
|
||
|
||
style = secrets.randbelow(100)
|
||
if style < 40:
|
||
# firstlast + digits e.g. jameswilson87
|
||
base = f"{first}{last}"
|
||
elif style < 65:
|
||
# first.sep.last + digits e.g. james.wilson87 / james_wilson42
|
||
base = f"{first}{sep}{last}" if sep else f"{first}{last}"
|
||
elif style < 80:
|
||
# first + digits e.g. james87
|
||
base = first
|
||
elif style < 92:
|
||
# first 首字母 + last + digits e.g. jwilson87
|
||
base = f"{first[0]}{last}"
|
||
else:
|
||
# last + first 首字母 + digits e.g. wilsonj87
|
||
base = f"{last}{first[0]}"
|
||
|
||
local = f"{base}{digits}".lower()
|
||
# 邮箱 local 只保留 [a-z0-9._],去掉连续分隔符
|
||
local = re.sub(r"[^a-z0-9._]", "", local)
|
||
local = re.sub(r"[._]{2,}", ".", local).strip("._")
|
||
if len(local) < 5:
|
||
local = f"{first}{digits}"
|
||
# 过长时截断名字部分,保留数字后缀
|
||
if len(local) > 24:
|
||
local = f"{local[:-digit_len][: 24 - digit_len]}{digits}"
|
||
return local or f"user{digits}"
|
||
|
||
|
||
def pick_domain(api_key=None):
|
||
domains = get_domains(api_key=api_key)
|
||
if not domains:
|
||
raise Exception("DuckMail 娌℃湁杩斿洖浠讳綍鍙敤鍩熷悕")
|
||
private = [d for d in domains if d.get("ownerId")]
|
||
verified_private = [d for d in private if d.get("isVerified")]
|
||
if verified_private:
|
||
return verified_private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
raise Exception("DuckMail 鏃犲凡楠岃瘉鍩熷悕鍙敤")
|
||
|
||
|
||
def get_email_provider():
|
||
return str(config.get("email_provider", "bblbb") or "bblbb").strip().lower()
|
||
|
||
|
||
def get_bblbb_api_base():
|
||
return str(config.get("bblbb_api_base", "") or "https://mail.bblbb.com").strip().rstrip("/")
|
||
|
||
|
||
def get_bblbb_api_key():
|
||
return str(config.get("bblbb_api_key", "") or "").strip()
|
||
|
||
|
||
def get_bblbb_domain():
|
||
domain = str(config.get("bblbb_domain", "") or "mail.bblbb.com").strip().lower()
|
||
# 兼容 defaultDomains 里只写了 bblbb 域名的情况
|
||
if not domain:
|
||
domains = _parse_domain_list(config.get("defaultDomains") or "")
|
||
for d in domains:
|
||
if "bblbb" in d or d.endswith("mail.bblbb.com"):
|
||
return d
|
||
return "mail.bblbb.com"
|
||
return domain.lstrip("@")
|
||
|
||
|
||
def bblbb_headers(api_key=None):
|
||
key = (api_key or get_bblbb_api_key()).strip()
|
||
if not key:
|
||
raise Exception("bblbb_api_key 未配置")
|
||
return {"X-Api-Key": key, "Accept": "application/json"}
|
||
|
||
|
||
def bblbb_local_part(email):
|
||
"""从完整邮箱或 token 里取出 local-part(@ 前)。"""
|
||
s = str(email or "").strip()
|
||
if s.lower().startswith("bblbb:"):
|
||
s = s.split(":", 1)[1]
|
||
if "@" in s:
|
||
return s.rsplit("@", 1)[0].strip()
|
||
return s
|
||
|
||
|
||
def bblbb_get_email_and_token(api_key=None):
|
||
"""自建临时邮箱:任意 local-part 可收,无需注册账号。
|
||
|
||
返回 (address, token),token 形如 bblbb:<local> 供 get_oai_code 识别。
|
||
"""
|
||
domain = get_bblbb_domain()
|
||
# 优先用 defaultDomains 里的 bblbb 域名(若配置了)
|
||
domains = _parse_domain_list(config.get("defaultDomains") or "")
|
||
for d in domains:
|
||
if d == domain or d.endswith(domain) or "bblbb" in d:
|
||
domain = d
|
||
break
|
||
local = generate_username(10)
|
||
address = f"{local}@{domain}"
|
||
# 触发一次 list 确认 API/Key 可用(空邮箱返回 [])
|
||
base = get_bblbb_api_base()
|
||
headers = bblbb_headers(api_key=api_key)
|
||
try:
|
||
resp = http_get(
|
||
f"{base}/api/v1/mailbox/{local}",
|
||
headers=headers,
|
||
timeout=15,
|
||
proxies={},
|
||
)
|
||
if resp.status_code in (401, 403):
|
||
raise Exception(f"bblbb 鉴权失败 HTTP {resp.status_code}")
|
||
# 非 2xx 也不阻断发信(域名 catch-all 本身可收),只记状态
|
||
_ = resp.status_code
|
||
except Exception as exc:
|
||
# 网络异常时仍返回地址,取码阶段再报错
|
||
if "鉴权失败" in str(exc):
|
||
raise
|
||
return address, f"bblbb:{local}"
|
||
|
||
|
||
def bblbb_list_messages(local, api_key=None):
|
||
base = get_bblbb_api_base()
|
||
headers = bblbb_headers(api_key=api_key)
|
||
resp = http_get(
|
||
f"{base}/api/v1/mailbox/{local}",
|
||
headers=headers,
|
||
timeout=15,
|
||
proxies={},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict):
|
||
for k in ("messages", "mails", "items", "data"):
|
||
if isinstance(data.get(k), list):
|
||
return data[k]
|
||
return []
|
||
|
||
|
||
def bblbb_get_message_detail(local, msg_id, api_key=None):
|
||
base = get_bblbb_api_base()
|
||
headers = bblbb_headers(api_key=api_key)
|
||
resp = http_get(
|
||
f"{base}/api/v1/mailbox/{local}/{msg_id}",
|
||
headers=headers,
|
||
timeout=15,
|
||
proxies={},
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json() if resp.content else {}
|
||
|
||
|
||
def bblbb_message_text(detail):
|
||
"""从 bblbb 邮件详情拼出可解析正文。"""
|
||
parts = []
|
||
if not isinstance(detail, dict):
|
||
return ""
|
||
subject = str(detail.get("subject") or "")
|
||
if subject:
|
||
parts.append(subject)
|
||
body = detail.get("body")
|
||
if isinstance(body, dict):
|
||
text = body.get("text") or ""
|
||
html = body.get("html") or ""
|
||
if text:
|
||
parts.append(str(text))
|
||
if html:
|
||
parts.append(re.sub(r"<[^>]+>", " ", str(html)))
|
||
elif isinstance(body, str) and body.strip():
|
||
parts.append(body)
|
||
for field in ("text", "html", "raw", "content", "snippet"):
|
||
value = detail.get(field)
|
||
if isinstance(value, str) and value.strip():
|
||
if field == "html":
|
||
parts.append(re.sub(r"<[^>]+>", " ", value))
|
||
else:
|
||
parts.append(value)
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, str) and item.strip():
|
||
parts.append(
|
||
re.sub(r"<[^>]+>", " ", item) if field == "html" else item
|
||
)
|
||
return "\n".join(parts)
|
||
|
||
|
||
def bblbb_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=None,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
api_key=None,
|
||
):
|
||
"""轮询 mail.bblbb.com 收件箱,从 body.text/html 提取验证码。"""
|
||
local = bblbb_local_part(dev_token) or bblbb_local_part(email)
|
||
if not local:
|
||
raise Exception("bblbb 邮箱 local-part 为空")
|
||
if poll_interval is None:
|
||
try:
|
||
poll_interval = float(config.get("bblbb_poll_interval", 2.0) or 2.0)
|
||
except Exception:
|
||
poll_interval = 2.0
|
||
poll_interval = max(0.5, min(float(poll_interval), 10.0))
|
||
deadline = time.time() + max(10.0, float(timeout))
|
||
seen_ids = set()
|
||
next_resend_at = time.time() + 35
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] 取码:bblbb ({local}@{get_bblbb_domain()}, "
|
||
f"timeout={timeout}s interval={poll_interval}s)"
|
||
)
|
||
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("[Debug] bblbb: 已触发重新发送验证码")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] bblbb: 触发重发失败: {exc}")
|
||
next_resend_at = time.time() + 35
|
||
try:
|
||
messages = bblbb_list_messages(local, api_key=api_key)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] bblbb 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
if log_callback and messages:
|
||
log_callback(f"[Debug] bblbb 本轮邮件数量: {len(messages)}")
|
||
# 优先新邮件:按 posix-millis / date 倒序
|
||
def _msg_sort_key(m):
|
||
if not isinstance(m, dict):
|
||
return 0
|
||
try:
|
||
return int(m.get("posix-millis") or 0)
|
||
except Exception:
|
||
return 0
|
||
|
||
ordered = sorted(
|
||
[m for m in messages if isinstance(m, dict)],
|
||
key=_msg_sort_key,
|
||
reverse=True,
|
||
) or [m for m in messages if isinstance(m, dict)]
|
||
for msg in ordered:
|
||
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)
|
||
subject = str(msg.get("subject") or "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] bblbb 收到邮件: {subject or msg_id}")
|
||
try:
|
||
detail = bblbb_get_message_detail(local, msg_id, api_key=api_key)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] bblbb 获取邮件详情失败: {exc}")
|
||
# 列表项本身可能没有正文,跳过
|
||
continue
|
||
combined = bblbb_message_text(detail)
|
||
if not combined.strip():
|
||
# 兜底:把列表字段也拼进去
|
||
combined = bblbb_message_text(msg)
|
||
code = extract_verification_code(combined, subject or detail.get("subject", ""))
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[Debug] bblbb 提取验证码: {code}")
|
||
return code
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] bblbb 邮件无验证码可解析 id={msg_id} "
|
||
f"preview={(combined or '')[:80]!r}"
|
||
)
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"bblbb 在 {timeout}s 内未收到验证码邮件: {local}@{get_bblbb_domain()}")
|
||
|
||
|
||
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 _parse_domain_list(raw):
|
||
return [x.strip().lower() for x in re.split(r"[,,\s]+", str(raw or "")) if x.strip()]
|
||
|
||
|
||
def cf_worker_domain_set():
|
||
"""走 CF Email Routing Worker 取码的域名集合(非 temp-email)。"""
|
||
return set(_parse_domain_list(config.get("cf_worker_domains") or config.get("gmail_worker_domains") or ""))
|
||
|
||
|
||
def next_register_domain():
|
||
"""从 defaultDomains 轮换取下一个注册邮箱域名。"""
|
||
global _cf_domain_index
|
||
domains = _parse_domain_list(config.get("defaultDomains") or "")
|
||
if not domains:
|
||
return ""
|
||
with _cf_domain_lock:
|
||
domain = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
return domain
|
||
|
||
|
||
def is_cf_worker_token(dev_token):
|
||
tok = str(dev_token or "").strip().lower()
|
||
return tok in ("cf_worker", "gmail_catch_all", "cf_email_worker") or tok.startswith("cf_worker:")
|
||
|
||
|
||
def _gmail_worker_enabled():
|
||
if not _config_bool(config.get("gmail_worker_enabled", False), default=False):
|
||
return False
|
||
url = str(config.get("gmail_worker_url", "") or os.environ.get("GMAIL_WORKER_URL", "") or "").strip()
|
||
key = str(config.get("gmail_worker_api_key", "") or os.environ.get("GMAIL_WORKER_API_KEY", "") or "").strip()
|
||
return bool(url and key)
|
||
|
||
|
||
def _gmail_worker_base_url():
|
||
return str(config.get("gmail_worker_url", "") or os.environ.get("GMAIL_WORKER_URL", "") or "").strip().rstrip("/")
|
||
|
||
|
||
def _gmail_worker_api_key():
|
||
return str(config.get("gmail_worker_api_key", "") or os.environ.get("GMAIL_WORKER_API_KEY", "") or "").strip()
|
||
|
||
|
||
def gmail_worker_fetch_code(
|
||
email,
|
||
*,
|
||
timeout=25.0,
|
||
poll_interval=None,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
consume=True,
|
||
):
|
||
"""轮询 CF Email Worker GET /code?to=... 取验证码(非 temp-email)。"""
|
||
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:
|
||
# Worker 直连,不走注册代理
|
||
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:
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
data = {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
code = str(data.get("code") or "").strip()
|
||
if code:
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] CF Worker 取到验证码: {code} "
|
||
f"(elapsed={time.time() - t0:.1f}s polls={polls})"
|
||
)
|
||
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] CF Worker 鉴权失败 HTTP {resp.status_code}")
|
||
if polls >= 3:
|
||
return None
|
||
else:
|
||
last_err = f"HTTP {resp.status_code}"
|
||
if log_callback and polls <= 3:
|
||
log_callback(
|
||
f"[Debug] CF Worker 响应 {resp.status_code}: {(resp.text or '')[:120]}"
|
||
)
|
||
except Exception as exc:
|
||
last_err = str(exc)
|
||
if log_callback and polls <= 3:
|
||
log_callback(f"[Debug] CF Worker 请求失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[*] CF Worker {timeout:.0f}s 内未取到验证码 ({last_err or 'timeout'})"
|
||
)
|
||
return None
|
||
|
||
|
||
def cf_worker_get_email_and_token(domain=None):
|
||
"""CF catch-all 域名:本地生成别名,不调用 temp-email 创建接口。"""
|
||
domains = _parse_domain_list(config.get("defaultDomains") or "")
|
||
worker_domains = cf_worker_domain_set()
|
||
if domain:
|
||
use_domain = domain.strip().lower()
|
||
elif worker_domains:
|
||
# 优先 worker 域名列表与 defaultDomains 交集
|
||
candidates = [d for d in domains if d in worker_domains] or list(worker_domains)
|
||
use_domain = candidates[0]
|
||
else:
|
||
if not domains:
|
||
raise Exception("CF Worker 模式需要 defaultDomains,如 zhangyunuo.net")
|
||
use_domain = domains[0]
|
||
username = generate_username(10)
|
||
address = f"{username}@{use_domain}"
|
||
return address, "cf_worker"
|
||
|
||
|
||
def cf_worker_get_oai_code(
|
||
email,
|
||
timeout=180,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
"""优先 CF Worker HTTP 取码;可选 IMAP 兜底(需配置 gmail_imap_*)。"""
|
||
if not _gmail_worker_enabled():
|
||
raise Exception(
|
||
"域名走 CF Worker 取码,但未配置 gmail_worker_enabled/url/api_key"
|
||
)
|
||
try:
|
||
worker_timeout = float(config.get("gmail_worker_timeout_sec", 20) or 20)
|
||
except Exception:
|
||
worker_timeout = 20.0
|
||
worker_timeout = max(4.0, min(worker_timeout, float(timeout)))
|
||
if log_callback:
|
||
log_callback(f"[*] 取码:CF Email Worker(timeout={worker_timeout:.0f}s){email}")
|
||
code = gmail_worker_fetch_code(
|
||
email,
|
||
timeout=worker_timeout,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
consume=True,
|
||
)
|
||
if code:
|
||
return code
|
||
|
||
# 可选:Worker 未命中时触发重发再等一轮
|
||
if resend_callback:
|
||
try:
|
||
resend_callback()
|
||
if log_callback:
|
||
log_callback("[*] CF Worker 未命中,已触发重发验证码,再等一轮")
|
||
except Exception:
|
||
pass
|
||
code = gmail_worker_fetch_code(
|
||
email,
|
||
timeout=min(15.0, worker_timeout),
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
consume=True,
|
||
)
|
||
if code:
|
||
return code
|
||
|
||
raise Exception(f"CF Worker 在 {worker_timeout:.0f}s 内未收到验证码: {email}")
|
||
|
||
|
||
def get_email_and_token(api_key=None):
|
||
provider = get_email_provider()
|
||
if provider in ("bblbb", "mail.bblbb", "mail.bblbb.com", "bblbb_mail"):
|
||
return bblbb_get_email_and_token(api_key=api_key)
|
||
if provider == "yyds":
|
||
return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt())
|
||
if provider in ("gmail", "cf_worker", "cf-email-worker"):
|
||
return cf_worker_get_email_and_token()
|
||
if provider == "cloudflare":
|
||
# 双通道:defaultDomains 里 worker 域名本地生成;其余走 temp-email
|
||
domain = next_register_domain()
|
||
worker_domains = cf_worker_domain_set()
|
||
if domain and domain in worker_domains:
|
||
if not _gmail_worker_enabled():
|
||
raise Exception(
|
||
f"域名 {domain} 配置为 CF Worker,但 gmail_worker_* 未就绪"
|
||
)
|
||
return cf_worker_get_email_and_token(domain=domain)
|
||
api_base = get_cloudflare_api_base()
|
||
if not api_base:
|
||
raise Exception("Cloudflare API Base 未配置")
|
||
try:
|
||
return cloudflare_create_temp_address(api_base, preferred_domain=domain or None)
|
||
except Exception as primary_exc:
|
||
# 若 temp-email 失败且 worker 可用,可回退到 worker 域名
|
||
if worker_domains and _gmail_worker_enabled():
|
||
fallback = next(iter(worker_domains))
|
||
return cf_worker_get_email_and_token(domain=fallback)
|
||
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()
|
||
# 按 token / 收件域名分流:worker 域名绝不走 temp-email 拉信
|
||
email_domain = ""
|
||
if email and "@" in email:
|
||
email_domain = email.rsplit("@", 1)[-1].strip().lower()
|
||
tok = str(dev_token or "").strip().lower()
|
||
if (
|
||
tok.startswith("bblbb:")
|
||
or provider in ("bblbb", "mail.bblbb", "mail.bblbb.com", "bblbb_mail")
|
||
or (email_domain and ("bblbb.com" in email_domain or email_domain == get_bblbb_domain()))
|
||
):
|
||
return bblbb_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 (
|
||
is_cf_worker_token(dev_token)
|
||
or provider in ("gmail", "cf_worker", "cf-email-worker")
|
||
or (email_domain and email_domain in cf_worker_domain_set())
|
||
):
|
||
return cf_worker_get_oai_code(
|
||
email,
|
||
timeout=timeout,
|
||
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 == "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"[Debug] 浠庨偖浠朵腑鎻愬彇鍒伴獙璇佺爜: {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("[Debug] 已触发重新发送验证码")
|
||
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"[Debug] Cloudflare 从邮件中提取到验证码: {code}")
|
||
return code
|
||
elif log_callback:
|
||
log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"Cloudflare 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
|
||
def generate_random_birthdate():
|
||
import datetime as dt
|
||
|
||
today = dt.date.today()
|
||
age = random.randint(20, 40)
|
||
birth_year = today.year - age
|
||
birth_month = random.randint(1, 12)
|
||
birth_day = random.randint(1, 28)
|
||
return f"{birth_year}-{birth_month:02d}-{birth_day:02d}T16:00:00.000Z"
|
||
|
||
|
||
def response_preview(res, limit=200):
|
||
try:
|
||
text = str(res.text or "")
|
||
except Exception:
|
||
text = ""
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
return text[:limit]
|
||
|
||
|
||
def is_cloudflare_block_response(res):
|
||
try:
|
||
headers = {str(k).lower(): str(v).lower() for k, v in dict(res.headers).items()}
|
||
text = str(res.text or "").lower()
|
||
server = headers.get("server", "")
|
||
content_type = headers.get("content-type", "")
|
||
return (
|
||
res.status_code in (403, 429, 503)
|
||
and (
|
||
"cloudflare" in server
|
||
or "cloudflare" in text
|
||
or "cf-error" in text
|
||
or "__cf_chl" in text
|
||
or "text/html" in content_type
|
||
)
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def set_birth_date(session, log_callback=None):
|
||
url = "https://grok.com/rest/auth/set-birth-date"
|
||
new_headers = {
|
||
"content-type": "application/json",
|
||
"origin": "https://grok.com",
|
||
"referer": "https://grok.com/",
|
||
}
|
||
payload = {"birthDate": generate_random_birthdate()}
|
||
try:
|
||
res = session.post(url, json=payload, headers=new_headers, timeout=15)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] set_birth_date status: {res.status_code}, body: {response_preview(res)}"
|
||
)
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
if is_cloudflare_block_response(res):
|
||
return (
|
||
False,
|
||
"set_birth_date 被 grok.com 的 Cloudflare 防护拦截,HTTP "
|
||
f"{res.status_code}",
|
||
)
|
||
return False, f"set_birth_date HTTP {res.status_code}: {response_preview(res)}"
|
||
except Exception as e:
|
||
# TLS 库损坏类错误上抛,供 enable_nsfw_for_token 切到 std requests
|
||
if _is_curl_tls_error(e):
|
||
raise
|
||
if log_callback:
|
||
log_callback(f"[set_birth_date] 异常: {e}")
|
||
return False, f"set_birth_date 异常: {e}"
|
||
|
||
|
||
def set_tos_accepted(session, log_callback=None):
|
||
url = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion"
|
||
payload = struct.pack("B", (2 << 3) | 0) + struct.pack("B", 1)
|
||
data = b"\x00" + struct.pack(">I", len(payload)) + payload
|
||
new_headers = {
|
||
"content-type": "application/grpc-web+proto",
|
||
"x-grpc-web": "1",
|
||
"x-user-agent": "connect-es/2.1.1",
|
||
"origin": "https://accounts.x.ai",
|
||
"referer": "https://accounts.x.ai/accept-tos",
|
||
}
|
||
try:
|
||
res = session.post(url, data=data, headers=new_headers, timeout=15)
|
||
if log_callback:
|
||
log_callback(f"[Debug] set_tos_accepted status: {res.status_code}")
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
if is_cloudflare_block_response(res):
|
||
return (
|
||
False,
|
||
"set_tos_accepted 被 accounts.x.ai 的 Cloudflare 防护拦截,HTTP "
|
||
f"{res.status_code}",
|
||
)
|
||
return False, f"set_tos_accepted HTTP {res.status_code}: {response_preview(res)}"
|
||
except Exception as e:
|
||
if _is_curl_tls_error(e):
|
||
raise
|
||
if log_callback:
|
||
log_callback(f"[set_tos_accepted] 异常: {e}")
|
||
return False, f"set_tos_accepted 异常: {e}"
|
||
|
||
|
||
def encode_grpc_nsfw_settings():
|
||
field1_content = bytes([0x10, 0x01])
|
||
field1 = bytes([0x0A, len(field1_content)]) + field1_content
|
||
nsfw_string = b"always_show_nsfw_content"
|
||
field2_inner = bytes([0x0A, len(nsfw_string)]) + nsfw_string
|
||
field2 = bytes([0x12, len(field2_inner)]) + field2_inner
|
||
payload = field1 + field2
|
||
return b"\x00" + struct.pack(">I", len(payload)) + payload
|
||
|
||
|
||
def update_nsfw_settings(session, log_callback=None):
|
||
url = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls"
|
||
data = encode_grpc_nsfw_settings()
|
||
new_headers = {
|
||
"content-type": "application/grpc-web+proto",
|
||
"x-grpc-web": "1",
|
||
"origin": "https://grok.com",
|
||
"referer": "https://grok.com/",
|
||
}
|
||
try:
|
||
res = session.post(url, data=data, headers=new_headers, timeout=15)
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] update_nsfw status: {res.status_code}, body: {response_preview(res)}"
|
||
)
|
||
if 200 <= res.status_code < 300:
|
||
return True, "ok"
|
||
if is_cloudflare_block_response(res):
|
||
return (
|
||
False,
|
||
"update_nsfw_settings 被 grok.com 的 Cloudflare 防护拦截,HTTP "
|
||
f"{res.status_code}",
|
||
)
|
||
return False, f"update_nsfw_settings HTTP {res.status_code}: {response_preview(res)}"
|
||
except Exception as e:
|
||
if _is_curl_tls_error(e):
|
||
raise
|
||
if log_callback:
|
||
log_callback(f"[update_nsfw] 异常: {e}")
|
||
return False, f"update_nsfw_settings 异常: {e}"
|
||
|
||
|
||
def _is_curl_tls_error(exc) -> bool:
|
||
"""curl_cffi 库损坏 / TLS / 连接超时:可尝试换后端或记失败,勿当业务错误。"""
|
||
text = str(exc or "").lower()
|
||
return any(
|
||
n in text
|
||
for n in (
|
||
"openssl_internal:invalid library",
|
||
"tls connect error",
|
||
"curl: (35)",
|
||
"curl: (28)",
|
||
"failed to perform, curl: (35)",
|
||
"failed to perform, curl: (28)",
|
||
"connection timed out",
|
||
"ssl_error_syscall",
|
||
"ssl connect error",
|
||
"readtimeout",
|
||
"connecttimeout",
|
||
"timed out",
|
||
"timeout",
|
||
)
|
||
)
|
||
|
||
|
||
def _make_post_reg_session(proxies=None, *, prefer: str = "curl"):
|
||
"""注册后 set_tos / nsfw 用的 session;curl TLS 坏了时回退 std requests。"""
|
||
prefer = (prefer or "curl").strip().lower()
|
||
if prefer != "requests":
|
||
try:
|
||
return requests.Session(impersonate="chrome120", proxies=proxies)
|
||
except TypeError:
|
||
# 极老 curl_cffi 无 impersonate
|
||
try:
|
||
return requests.Session(proxies=proxies)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
try:
|
||
import requests as std_requests
|
||
except ImportError as exc: # pragma: no cover
|
||
raise RuntimeError("需要 curl_cffi 或 requests") from exc
|
||
sess = std_requests.Session()
|
||
if proxies:
|
||
sess.proxies.update(proxies)
|
||
return sess
|
||
|
||
|
||
def enable_nsfw_for_token(token, cf_clearance="", log_callback=None):
|
||
proxies = get_proxies()
|
||
user_agent = get_user_agent()
|
||
log = log_callback or (lambda *_: None)
|
||
|
||
def _run(session):
|
||
cookie_parts = [f"sso={token}", f"sso-rw={token}"]
|
||
if cf_clearance:
|
||
cookie_parts.append(f"cf_clearance={cf_clearance}")
|
||
session.headers.update(
|
||
{
|
||
"user-agent": user_agent,
|
||
"cookie": "; ".join(cookie_parts),
|
||
}
|
||
)
|
||
ok, message = set_tos_accepted(session, log_callback)
|
||
if not ok:
|
||
return False, message
|
||
ok, message = set_birth_date(session, log_callback)
|
||
if not ok:
|
||
return False, message
|
||
ok, message = update_nsfw_settings(session, log_callback)
|
||
if not ok:
|
||
return False, message
|
||
return True, "成功开启 NSFW"
|
||
|
||
def _close(session):
|
||
try:
|
||
session.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# 仅用 curl_cffi:本机实测 std requests 对 accounts.x.ai/auth.x.ai 更易 ReadTimeout
|
||
try:
|
||
session = _make_post_reg_session(proxies, prefer="curl")
|
||
try:
|
||
return _run(session)
|
||
except Exception as exc:
|
||
if _is_curl_tls_error(exc):
|
||
log(f"[Debug] set_tos/nsfw 网络/TLS 失败(不回退 requests): {exc}")
|
||
return False, f"网络/TLS: {exc}"
|
||
return False, f"异常: {exc}"
|
||
finally:
|
||
_close(session)
|
||
except Exception as e:
|
||
return False, f"异常: {str(e)}"
|
||
|
||
|
||
SIGNUP_URL = "https://accounts.x.ai/sign-up?redirect=grok-com"
|
||
|
||
|
||
class BrowserSession:
|
||
"""每个并发 worker 独立持有一个浏览器实例,取代原来的全局 browser/page。"""
|
||
|
||
# 屏蔽的静态资源(省带宽);刻意不含 Turnstile 依赖的脚本/域名
|
||
BLOCKED_URLS = [
|
||
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.svg", "*.ico", "*.bmp",
|
||
"*.woff", "*.woff2", "*.ttf", "*.otf", "*.eot",
|
||
"*.mp4", "*.webm", "*.m4v", "*.mp3", "*.ogg", "*.wav",
|
||
]
|
||
|
||
# 内联 turnstilePatch 扩展的核心补丁:在每个页面/子 frame 的最早期伪造
|
||
# MouseEvent 的 screenX/screenY(随机屏幕基准 + clientX/Y),堵住 Cloudflare
|
||
# 检测自动化鼠标事件的通道。用 add_init_js 全局注入,无需外部 turnstilePatch 目录。
|
||
STEALTH_INIT_JS = r"""
|
||
(function () {
|
||
try {
|
||
function _rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||
var _sx = _rnd(800, 1200);
|
||
var _sy = _rnd(400, 600);
|
||
Object.defineProperty(MouseEvent.prototype, 'screenX', {
|
||
configurable: true,
|
||
get: function () { return _sx + this.clientX; }
|
||
});
|
||
Object.defineProperty(MouseEvent.prototype, 'screenY', {
|
||
configurable: true,
|
||
get: function () { return _sy + this.clientY; }
|
||
});
|
||
} catch (e) {}
|
||
})();
|
||
"""
|
||
|
||
def __init__(self, log_callback=None, proxy=None):
|
||
self.browser = None
|
||
self.page = None
|
||
self.log_callback = log_callback
|
||
self.proxy = proxy # 当前绑定出口;None=启动时从池取
|
||
self._pid = None
|
||
self._port = None
|
||
|
||
def _log(self, message):
|
||
if self.log_callback:
|
||
self.log_callback(message)
|
||
|
||
def bind_proxy(self, proxy=None, rotate=False):
|
||
"""绑定本 session 的出口。rotate=True 时从池取下一枚。"""
|
||
try:
|
||
import proxy_pool
|
||
if rotate or (proxy is None and proxy_pool.is_enabled()):
|
||
p = proxy_pool.acquire(force=True)
|
||
elif proxy is not None:
|
||
p = proxy_pool.set_thread_proxy(proxy)
|
||
else:
|
||
p = proxy_pool.get_thread_proxy() or proxy_pool.normalize_proxy(
|
||
config.get("proxy") or ""
|
||
)
|
||
proxy_pool.set_thread_proxy(p)
|
||
self.proxy = p
|
||
return p
|
||
except Exception:
|
||
p = (proxy if proxy is not None else config.get("proxy") or "") or ""
|
||
self.proxy = str(p).strip()
|
||
return self.proxy
|
||
|
||
def _apply_bandwidth_rules(self):
|
||
# 页面级拦截字体/媒体等静态资源省带宽(可选,默认关;可能影响验证渲染)
|
||
if self.page is None or not config.get("block_media_fonts", False):
|
||
return
|
||
try:
|
||
self.page.set.blocked_urls(self.BLOCKED_URLS)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_stealth_patch(self):
|
||
# 内联 turnstilePatch 补丁(可选,默认关;会改主文档指纹,可能弄巧成拙)
|
||
if self.page is None or not config.get("stealth_patch", False):
|
||
return
|
||
try:
|
||
self.page.add_init_js(self.STEALTH_INIT_JS)
|
||
except Exception:
|
||
pass
|
||
|
||
def start(self, rotate_proxy=False):
|
||
# 启动前绑定代理(代理池开启时 rotate 可换出口)
|
||
self.bind_proxy(proxy=self.proxy, rotate=rotate_proxy)
|
||
try:
|
||
import proxy_pool
|
||
self._log(f"[Debug] 浏览器代理={proxy_pool.proxy_log_label(self.proxy)}")
|
||
except Exception:
|
||
self._log(f"[Debug] 浏览器代理={self.proxy or '(direct)'}")
|
||
|
||
last_exc = None
|
||
for attempt in range(1, 5):
|
||
try:
|
||
# 确保线程代理与 session 一致(create_browser_options 会读线程绑定)
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.set_thread_proxy(self.proxy)
|
||
except Exception:
|
||
pass
|
||
self.browser = Chromium(create_browser_options(proxy=self.proxy))
|
||
try:
|
||
self._pid = self.browser.process_id
|
||
except Exception:
|
||
self._pid = None
|
||
try:
|
||
self._port = str(self.browser.address).rsplit(":", 1)[-1].strip()
|
||
except Exception:
|
||
self._port = None
|
||
tabs = self.browser.get_tabs()
|
||
self.page = tabs[-1] if tabs else self.browser.new_tab()
|
||
# 仅当开启了页面级钩子时才预热(确保 CDP 就绪);
|
||
# 默认全关时不预热,start 行为与最初脚本完全一致
|
||
if config.get("block_media_fonts", False) or config.get(
|
||
"stealth_patch", False
|
||
):
|
||
try:
|
||
self.page.get("about:blank")
|
||
except Exception:
|
||
pass
|
||
self._apply_bandwidth_rules()
|
||
self._apply_stealth_patch()
|
||
if attempt > 1:
|
||
self._log(f"[Debug] 浏览器第 {attempt} 次启动成功")
|
||
return self.browser, self.page
|
||
except Exception as exc:
|
||
last_exc = exc
|
||
self._log(f"[Debug] 浏览器启动失败(第{attempt}/4次): {exc}")
|
||
try:
|
||
if self.browser is not None:
|
||
self.browser.quit(del_data=True)
|
||
except Exception:
|
||
pass
|
||
self.browser = None
|
||
self.page = None
|
||
time.sleep(min(1.5 * attempt, 4))
|
||
raise Exception(f"浏览器启动失败,已重试4次: {last_exc}")
|
||
|
||
def stop(self):
|
||
pid = getattr(self, "_pid", None)
|
||
port = getattr(self, "_port", None)
|
||
if self.browser is not None:
|
||
try:
|
||
self.browser.quit(del_data=True)
|
||
except Exception:
|
||
pass
|
||
# quit 关不干净时兜底:按调试端口定位真正的 chrome 主进程并强杀整棵树
|
||
_kill_chrome_session(pid, port)
|
||
self.browser = None
|
||
self.page = None
|
||
self._pid = None
|
||
self._port = None
|
||
|
||
def restart(self, rotate_proxy=None):
|
||
"""重启浏览器。rotate_proxy 默认跟随 proxy_rotate_each_account。"""
|
||
if rotate_proxy is None:
|
||
try:
|
||
import proxy_pool
|
||
rotate_proxy = proxy_pool.is_enabled() and proxy_pool.rotate_each_account()
|
||
except Exception:
|
||
rotate_proxy = False
|
||
self.stop()
|
||
gc.collect()
|
||
if rotate_proxy:
|
||
self.proxy = None # 强制 acquire 新出口
|
||
return self.start(rotate_proxy=bool(rotate_proxy))
|
||
|
||
def refresh_page(self):
|
||
if self.browser is None:
|
||
self.restart(rotate_proxy=False)
|
||
return self.page
|
||
try:
|
||
tabs = self.browser.get_tabs()
|
||
self.page = tabs[-1] if tabs else self.browser.new_tab()
|
||
self._apply_bandwidth_rules()
|
||
except Exception:
|
||
self.restart(rotate_proxy=False)
|
||
return self.page
|
||
|
||
|
||
def click_email_signup_button(session, timeout=10, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if log_callback:
|
||
log_callback("[Debug] 尝试查找“使用邮箱注册”按钮...")
|
||
|
||
clicked = page.run_js(r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function nodeText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('href'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function scoreEntry(node) {
|
||
const compact = nodeText(node).replace(/\s+/g, '');
|
||
const lower = compact.toLowerCase();
|
||
if (compact.includes('使用邮箱注册')) return 100;
|
||
if (lower.includes('signupwithemail')) return 95;
|
||
if (lower.includes('continuewithemail')) return 90;
|
||
if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80;
|
||
if (lower === 'email' || lower.includes('邮箱')) return 70;
|
||
return 0;
|
||
}
|
||
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
||
.map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) }))
|
||
.filter((item) => item.score > 0)
|
||
.sort((a, b) => b.score - a.score);
|
||
const target = candidates[0]?.node || null;
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
target.click();
|
||
return candidates[0].text || true;
|
||
""")
|
||
|
||
if clicked:
|
||
if log_callback:
|
||
detail = f": {clicked}" if isinstance(clicked, str) else ""
|
||
log_callback(f"[Debug] 已点击「使用邮箱注册」按钮{detail}")
|
||
sleep_with_cancel(2, cancel_callback)
|
||
return True
|
||
|
||
if log_callback:
|
||
current_url = page.url if page else "none"
|
||
log_callback(f"[Debug] 当前URL: {current_url}")
|
||
|
||
sleep_with_cancel(1, cancel_callback)
|
||
|
||
if log_callback:
|
||
page_html = page.html[:500] if page else "no page"
|
||
log_callback(f"[Debug] 页面内容片段: {page_html}")
|
||
|
||
raise Exception("未找到「使用邮箱注册」按钮")
|
||
|
||
|
||
def open_signup_page(session, log_callback=None, cancel_callback=None):
|
||
raise_if_cancelled(cancel_callback)
|
||
if session.browser is None:
|
||
session.start()
|
||
if log_callback:
|
||
log_callback("[Debug] 浏览器已启动")
|
||
try:
|
||
page = session.browser.get_tab(0)
|
||
page.get(SIGNUP_URL)
|
||
except Exception as e:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 打开URL异常: {e}")
|
||
try:
|
||
page = session.browser.new_tab(SIGNUP_URL)
|
||
except Exception as e2:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 创建新标签页异常: {e2}")
|
||
session.restart()
|
||
page = session.browser.new_tab(SIGNUP_URL)
|
||
session.page = page
|
||
session._apply_bandwidth_rules()
|
||
page.wait.doc_loaded()
|
||
sleep_with_cancel(2, cancel_callback)
|
||
if log_callback:
|
||
log_callback(f"[Debug] 当前URL: {page.url}")
|
||
click_email_signup_button(
|
||
session, log_callback=log_callback, cancel_callback=cancel_callback
|
||
)
|
||
|
||
|
||
def has_profile_form(session, log_callback=None):
|
||
session.refresh_page()
|
||
page = session.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(session, timeout=45, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
raise_if_cancelled(cancel_callback)
|
||
email, dev_token = get_email_and_token()
|
||
if not email or not dev_token:
|
||
raise Exception("获取邮箱失败")
|
||
if log_callback:
|
||
log_callback(f"[Debug] 已创建邮箱: {email}")
|
||
deadline = time.time() + timeout
|
||
last_diag_time = 0
|
||
last_reclick_time = 0
|
||
last_snapshot = None
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
filled = page.run_js(
|
||
"""
|
||
const email = arguments[0];
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function textOf(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('placeholder'),
|
||
node.getAttribute('data-testid'),
|
||
node.getAttribute('name'),
|
||
node.getAttribute('id'),
|
||
node.getAttribute('autocomplete'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function describeInput(node) {
|
||
return [
|
||
`type=${node.getAttribute('type') || ''}`,
|
||
`name=${node.getAttribute('name') || ''}`,
|
||
`id=${node.getAttribute('id') || ''}`,
|
||
`placeholder=${node.getAttribute('placeholder') || ''}`,
|
||
`aria=${node.getAttribute('aria-label') || ''}`,
|
||
`testid=${node.getAttribute('data-testid') || ''}`,
|
||
].join(' ').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||
}
|
||
function describeAction(node) {
|
||
return textOf(node).slice(0, 120);
|
||
}
|
||
function emailCandidates() {
|
||
const direct = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"], input[placeholder*="mail" i], input[aria-label*="mail" i]'));
|
||
const all = Array.from(document.querySelectorAll('input, textarea'));
|
||
for (const node of all) {
|
||
const type = (node.getAttribute('type') || '').toLowerCase();
|
||
if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'search'].includes(type)) continue;
|
||
const meta = textOf(node).toLowerCase();
|
||
if (meta.includes('email') || meta.includes('e-mail') || meta.includes('mail') || meta.includes('邮箱') || meta.includes('电子邮件')) {
|
||
direct.push(node);
|
||
}
|
||
}
|
||
return Array.from(new Set(direct));
|
||
}
|
||
const visibleInputs = Array.from(document.querySelectorAll('input, textarea'))
|
||
.filter((node) => isVisible(node) && !node.disabled && !node.readOnly)
|
||
.map(describeInput)
|
||
.slice(0, 8);
|
||
const visibleActions = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
||
.map(describeAction)
|
||
.filter(Boolean)
|
||
.slice(0, 10);
|
||
const input = emailCandidates().find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null;
|
||
if (!input) {
|
||
return {
|
||
state: 'not-ready',
|
||
url: location.href,
|
||
title: document.title,
|
||
inputs: visibleInputs,
|
||
buttons: visibleActions,
|
||
};
|
||
}
|
||
input.focus(); input.click();
|
||
const valueProto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const valueSetter = Object.getOwnPropertyDescriptor(valueProto, 'value')?.set;
|
||
const tracker = input._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (valueSetter) valueSetter.call(input, email); else input.value = email;
|
||
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: email, inputType: 'insertText' }));
|
||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: email, inputType: 'insertText' }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
const inputType = (input.getAttribute('type') || '').toLowerCase();
|
||
const isValid = inputType !== 'email' || input.checkValidity();
|
||
if ((input.value || '').trim() !== email || !isValid) {
|
||
return {
|
||
state: 'fill-failed',
|
||
value: input.value || '',
|
||
valid: isValid,
|
||
input: describeInput(input),
|
||
url: location.href,
|
||
};
|
||
}
|
||
input.blur();
|
||
return {
|
||
state: 'filled',
|
||
input: describeInput(input),
|
||
url: location.href,
|
||
};
|
||
""",
|
||
email,
|
||
)
|
||
state = filled.get("state") if isinstance(filled, dict) else filled
|
||
if isinstance(filled, dict):
|
||
last_snapshot = filled
|
||
if state == "not-ready":
|
||
now = time.time()
|
||
if now - last_reclick_time >= 3:
|
||
reclicked = page.run_js(r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function nodeText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('href'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function scoreEntry(node) {
|
||
const compact = nodeText(node).replace(/\s+/g, '');
|
||
const lower = compact.toLowerCase();
|
||
if (compact.includes('使用邮箱注册')) return 100;
|
||
if (lower.includes('signupwithemail')) return 95;
|
||
if (lower.includes('continuewithemail')) return 90;
|
||
if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80;
|
||
if (lower === 'email' || lower.includes('邮箱')) return 70;
|
||
return 0;
|
||
}
|
||
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
||
.map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) }))
|
||
.filter((item) => item.score > 0)
|
||
.sort((a, b) => b.score - a.score);
|
||
if (!candidates.length) return false;
|
||
candidates[0].node.click();
|
||
return candidates[0].text || true;
|
||
""")
|
||
last_reclick_time = now
|
||
if reclicked and log_callback:
|
||
detail = f": {reclicked}" if isinstance(reclicked, str) else ""
|
||
log_callback(f"[Debug] 邮箱输入框未出现,已再次触发邮箱注册入口{detail}")
|
||
if log_callback and now - last_diag_time >= 5:
|
||
last_diag_time = now
|
||
inputs = " | ".join((filled or {}).get("inputs", [])[:6]) if isinstance(filled, dict) else ""
|
||
buttons = " | ".join((filled or {}).get("buttons", [])[:8]) if isinstance(filled, dict) else ""
|
||
url = (filled or {}).get("url", page.url if page else "") if isinstance(filled, dict) else (page.url if page else "")
|
||
log_callback(f"[Debug] 等待邮箱输入框: url={url}; inputs={inputs or 'none'}; buttons={buttons or 'none'}")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
if state != "filled":
|
||
if log_callback:
|
||
log_callback(f"[Debug] 邮箱输入框已出现,但写入失败: {filled}")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
sleep_with_cancel(0.8, cancel_callback)
|
||
clicked = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function textOf(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
node.getAttribute('placeholder'),
|
||
node.getAttribute('data-testid'),
|
||
node.getAttribute('name'),
|
||
node.getAttribute('id'),
|
||
node.getAttribute('autocomplete'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
function emailCandidates() {
|
||
const direct = Array.from(document.querySelectorAll('input[data-testid="email"], input[name="email"], input[type="email"], input[autocomplete="email"], input[placeholder*="mail" i], input[aria-label*="mail" i]'));
|
||
const all = Array.from(document.querySelectorAll('input, textarea'));
|
||
for (const node of all) {
|
||
const type = (node.getAttribute('type') || '').toLowerCase();
|
||
if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'search'].includes(type)) continue;
|
||
const meta = textOf(node).toLowerCase();
|
||
if (meta.includes('email') || meta.includes('e-mail') || meta.includes('mail') || meta.includes('邮箱') || meta.includes('电子邮件')) {
|
||
direct.push(node);
|
||
}
|
||
}
|
||
return Array.from(new Set(direct));
|
||
}
|
||
const input = emailCandidates().find((node) => isVisible(node) && !node.disabled && !node.readOnly) || null;
|
||
if (!input || !(input.value || '').trim()) return false;
|
||
const inputType = (input.getAttribute('type') || '').toLowerCase();
|
||
if (inputType === 'email' && !input.checkValidity()) return false;
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||
const submitButton = buttons.find((node) => {
|
||
const text = textOf(node).replace(/\s+/g, '');
|
||
const lower = text.toLowerCase();
|
||
return (
|
||
text === '注册' ||
|
||
text.includes('注册') ||
|
||
text.includes('继续') ||
|
||
text.includes('下一步') ||
|
||
text.includes('确认') ||
|
||
lower.includes('signup') ||
|
||
lower.includes('sign up') ||
|
||
lower.includes('continue') ||
|
||
lower.includes('next') ||
|
||
lower.includes('createaccount') ||
|
||
lower.includes('submit')
|
||
);
|
||
});
|
||
if (submitButton) {
|
||
submitButton.click();
|
||
return textOf(submitButton) || true;
|
||
}
|
||
const form = input.closest('form');
|
||
if (form) {
|
||
if (form.requestSubmit) form.requestSubmit();
|
||
else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||
return 'form-submit';
|
||
}
|
||
input.focus();
|
||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
||
return 'enter';
|
||
"""
|
||
)
|
||
if clicked:
|
||
if log_callback:
|
||
detail = f" ({clicked})" if isinstance(clicked, str) else ""
|
||
log_callback(f"[Debug] 已填写邮箱并提交: {email}{detail}")
|
||
return email, dev_token
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
if last_snapshot:
|
||
inputs = " | ".join(last_snapshot.get("inputs", [])[:6])
|
||
buttons = " | ".join(last_snapshot.get("buttons", [])[:8])
|
||
url = last_snapshot.get("url", page.url if page else "")
|
||
raise Exception(
|
||
f"未找到邮箱输入框或注册按钮,最后页面: url={url}; inputs={inputs or 'none'}; buttons={buttons or 'none'}"
|
||
)
|
||
raise Exception("未找到邮箱输入框或注册按钮")
|
||
|
||
|
||
def fill_code_and_submit(session, email, dev_token, timeout=180, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
|
||
def _resend_code():
|
||
page.run_js(
|
||
r"""
|
||
const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
||
const target = nodes.find((node) => {
|
||
const t = (node.innerText || node.textContent || '').replace(/\s+/g, '').toLowerCase();
|
||
return t.includes('重新发送') || t.includes('resend') || t.includes('再次发送');
|
||
});
|
||
if (target && !target.disabled) { target.click(); return true; }
|
||
return false;
|
||
"""
|
||
)
|
||
|
||
code = get_oai_code(
|
||
dev_token,
|
||
email,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=_resend_code,
|
||
)
|
||
if not code:
|
||
raise Exception("获取验证码失败")
|
||
clean_code = str(code).replace("-", "").strip()
|
||
deadline = time.time() + timeout
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
filled = page.run_js(
|
||
"""
|
||
const code = String(arguments[0] || '').trim();
|
||
if (!code) return 'empty-code';
|
||
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
function setInputValue(input, value) {
|
||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||
const tracker = input._valueTracker;
|
||
if (tracker) tracker.setValue('');
|
||
if (nativeSetter) nativeSetter.call(input, value);
|
||
else input.value = value;
|
||
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
const aggregate = Array.from(document.querySelectorAll(
|
||
'input[data-input-otp=\"true\"], input[name=\"code\"], input[autocomplete=\"one-time-code\"], input[inputmode=\"numeric\"], input[inputmode=\"text\"]'
|
||
)).find((node) => isVisible(node) && !node.disabled && !node.readOnly && Number(node.maxLength || 6) > 1);
|
||
|
||
if (aggregate) {
|
||
aggregate.focus();
|
||
aggregate.click();
|
||
setInputValue(aggregate, code);
|
||
return String(aggregate.value || '').replace(/\\s+/g, '') ? 'filled-aggregate' : 'aggregate-failed';
|
||
}
|
||
|
||
const otpBoxes = Array.from(document.querySelectorAll('input')).filter((node) => {
|
||
if (!isVisible(node) || node.disabled || node.readOnly) return false;
|
||
const maxLength = Number(node.maxLength || 0);
|
||
const ac = String(node.autocomplete || '').toLowerCase();
|
||
return maxLength === 1 || ac === 'one-time-code';
|
||
});
|
||
|
||
if (otpBoxes.length >= code.length) {
|
||
for (let i = 0; i < code.length; i += 1) {
|
||
const ch = code[i] || '';
|
||
const box = otpBoxes[i];
|
||
box.focus();
|
||
box.click();
|
||
setInputValue(box, ch);
|
||
box.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }));
|
||
box.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }));
|
||
}
|
||
const merged = otpBoxes.slice(0, code.length).map((x) => String(x.value || '').trim()).join('');
|
||
return merged.length ? 'filled-boxes' : 'boxes-failed';
|
||
}
|
||
|
||
return 'not-ready';
|
||
""",
|
||
clean_code,
|
||
)
|
||
|
||
if filled == "not-ready":
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
if "failed" in str(filled):
|
||
if log_callback:
|
||
log_callback(f"[Debug] 验证码填写失败: {filled}")
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
|
||
clicked = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
const buttons = Array.from(document.querySelectorAll('button[type=\"submit\"], button')).filter((node) => {
|
||
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
|
||
const btn = buttons.find((node) => {
|
||
const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase();
|
||
return (
|
||
t.includes('确认邮箱') ||
|
||
t.includes('继续') ||
|
||
t.includes('下一步') ||
|
||
t.includes('confirm') ||
|
||
t.includes('continue') ||
|
||
t.includes('next')
|
||
);
|
||
});
|
||
|
||
if (!btn) return 'no-button';
|
||
btn.focus();
|
||
btn.click();
|
||
return 'clicked';
|
||
"""
|
||
)
|
||
|
||
if clicked == "clicked" or clicked == "no-button":
|
||
if log_callback:
|
||
log_callback(f"[Debug] 已填写验证码并提交: {code}")
|
||
sleep_with_cancel(1.5, cancel_callback)
|
||
return code
|
||
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
|
||
raise Exception("验证码已获取,但自动填写/提交失败")
|
||
|
||
|
||
def _read_turnstile_token(page):
|
||
"""读取页面上的 Turnstile token(input / turnstile.getResponse)。"""
|
||
try:
|
||
token = page.run_js(
|
||
"""
|
||
try {
|
||
const byInput = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim();
|
||
if (byInput) return byInput;
|
||
if (window.turnstile && typeof turnstile.getResponse === 'function') {
|
||
const r = String(turnstile.getResponse() || '').trim();
|
||
if (r) return r;
|
||
}
|
||
// 有时 response 写在多个 hidden input
|
||
const all = Array.from(document.querySelectorAll('input[name*="turnstile"], textarea[name*="turnstile"]'));
|
||
for (const el of all) {
|
||
const v = String(el.value || '').trim();
|
||
if (v.length >= 80) return v;
|
||
}
|
||
return '';
|
||
} catch(e) { return ''; }
|
||
"""
|
||
)
|
||
return str(token or "").strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _inspect_turnstile_state(page):
|
||
"""探测 Turnstile 形态:visible checkbox / managed invisible / 是否已有 token。
|
||
|
||
返回 dict:
|
||
present, visible, mode('visible'|'managed'|'none'), iframe_count,
|
||
max_w, max_h, token_len, detail
|
||
"""
|
||
default = {
|
||
"present": False,
|
||
"visible": False,
|
||
"mode": "none",
|
||
"iframe_count": 0,
|
||
"max_w": 0,
|
||
"max_h": 0,
|
||
"token_len": 0,
|
||
"detail": "",
|
||
}
|
||
if page is None:
|
||
return default
|
||
try:
|
||
info = page.run_js(
|
||
r"""
|
||
try {
|
||
const token = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim();
|
||
const iframes = Array.from(document.querySelectorAll(
|
||
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], iframe[title*="Cloudflare"], iframe[title*="Turnstile"]'
|
||
));
|
||
const widgets = Array.from(document.querySelectorAll(
|
||
'div.cf-turnstile, [data-sitekey], [class*="cf-turnstile"], [id*="cf-turnstile"]'
|
||
));
|
||
let maxW = 0, maxH = 0, visible = false;
|
||
const sizes = [];
|
||
function noteRect(el, tag) {
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const w = Math.round(r.width || 0);
|
||
const h = Math.round(r.height || 0);
|
||
if (w > maxW) maxW = w;
|
||
if (h > maxH) maxH = h;
|
||
// 可见 checkbox 通常约 300x65;managed 常为 0x0
|
||
if (w >= 40 && h >= 20) visible = true;
|
||
sizes.push(tag + ':' + w + 'x' + h);
|
||
}
|
||
iframes.forEach((f, i) => noteRect(f, 'iframe' + i));
|
||
widgets.forEach((w, i) => noteRect(w, 'widget' + i));
|
||
// 父容器也看一下(有时 iframe 尺寸写在 wrapper)
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
if (cfInput && cfInput.parentElement) noteRect(cfInput.parentElement, 'cf-parent');
|
||
const present = !!(iframes.length || widgets.length || cfInput ||
|
||
document.querySelector('script[src*="turnstile"], script[src*="challenges.cloudflare"]'));
|
||
let mode = 'none';
|
||
if (present) mode = visible ? 'visible' : 'managed';
|
||
return {
|
||
present: present,
|
||
visible: visible,
|
||
mode: mode,
|
||
iframe_count: iframes.length,
|
||
max_w: maxW,
|
||
max_h: maxH,
|
||
token_len: token.length,
|
||
detail: sizes.slice(0, 6).join(',')
|
||
};
|
||
} catch (e) {
|
||
return { present: false, visible: false, mode: 'none', iframe_count: 0, max_w: 0, max_h: 0, token_len: 0, detail: String(e) };
|
||
}
|
||
"""
|
||
)
|
||
if not isinstance(info, dict):
|
||
return default
|
||
out = dict(default)
|
||
out.update({
|
||
"present": bool(info.get("present")),
|
||
"visible": bool(info.get("visible")),
|
||
"mode": str(info.get("mode") or "none"),
|
||
"iframe_count": int(info.get("iframe_count") or 0),
|
||
"max_w": int(info.get("max_w") or 0),
|
||
"max_h": int(info.get("max_h") or 0),
|
||
"token_len": int(info.get("token_len") or 0),
|
||
"detail": str(info.get("detail") or ""),
|
||
})
|
||
return out
|
||
except Exception as exc:
|
||
default["detail"] = str(exc)
|
||
return default
|
||
|
||
|
||
def _patch_mouse_screen_coords(iframe):
|
||
"""给 iframe 注入更像真人的 screenX/screenY,降低 isTrusted 路径被判定为自动化。"""
|
||
try:
|
||
iframe.run_js(
|
||
"""
|
||
window.dtp = 1;
|
||
function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||
let sx = getRandomInt(800, 1200);
|
||
let sy = getRandomInt(400, 700);
|
||
try {
|
||
Object.defineProperty(MouseEvent.prototype, 'screenX', { get: function(){ return sx + (this.clientX||0); } });
|
||
Object.defineProperty(MouseEvent.prototype, 'screenY', { get: function(){ return sy + (this.clientY||0); } });
|
||
} catch(e) {}
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _click_turnstile_checkbox(page, log_callback=None, force=False):
|
||
"""尽量真实地点击 Turnstile 复选框(shadow-root / iframe)。
|
||
|
||
- 可见 checkbox:立刻点
|
||
- managed/invisible(0x0):默认不点(force=True 时才轻点兜底)
|
||
返回 dict: clicked, visible, mode, detail
|
||
"""
|
||
log = log_callback or (lambda *_: None)
|
||
state = _inspect_turnstile_state(page)
|
||
result = {
|
||
"clicked": False,
|
||
"visible": bool(state.get("visible")),
|
||
"mode": str(state.get("mode") or "none"),
|
||
"detail": str(state.get("detail") or ""),
|
||
}
|
||
|
||
# managed/invisible:无可见目标时默认不点,避免打断自动校验
|
||
if not result["visible"] and not force:
|
||
log(
|
||
f"[Debug] Turnstile 形态={result['mode']} "
|
||
f"size={state.get('max_w')}x{state.get('max_h')},跳过点击(managed)"
|
||
)
|
||
return result
|
||
|
||
clicked = False
|
||
click_path = ""
|
||
|
||
# 1) 经典路径:cf-turnstile-response 父节点 shadow_root → iframe → body.shadow_root → input
|
||
try:
|
||
challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.5)
|
||
except Exception:
|
||
challenge_input = None
|
||
if challenge_input:
|
||
try:
|
||
wrapper = challenge_input.parent()
|
||
iframe = None
|
||
try:
|
||
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.5,
|
||
)
|
||
except Exception:
|
||
iframe = None
|
||
if iframe is not None:
|
||
_patch_mouse_screen_coords(iframe)
|
||
try:
|
||
# 优先点 iframe 本体(可见时更接近真人)
|
||
try:
|
||
iframe.click()
|
||
clicked = True
|
||
click_path = "iframe-self"
|
||
except Exception:
|
||
pass
|
||
body = iframe.ele("tag:body", timeout=1)
|
||
body_sr = body.shadow_root if body is not None else None
|
||
btn = None
|
||
if body_sr is not None:
|
||
for sel in (
|
||
"tag:input",
|
||
"css:input[type=checkbox]",
|
||
"css:input",
|
||
"css:.mark",
|
||
"css:label",
|
||
"css:#content",
|
||
"css:.cb-i",
|
||
):
|
||
try:
|
||
btn = body_sr.ele(sel, timeout=0.3)
|
||
except Exception:
|
||
btn = None
|
||
if btn is not None:
|
||
break
|
||
if btn is not None:
|
||
try:
|
||
btn.click()
|
||
except Exception:
|
||
try:
|
||
btn.click(by_js=True)
|
||
except Exception:
|
||
pass
|
||
clicked = True
|
||
click_path = click_path or "shadow-input"
|
||
log("[Debug] Turnstile shadow checkbox 已点击")
|
||
elif body is not None and not clicked:
|
||
try:
|
||
body.click()
|
||
clicked = True
|
||
click_path = "iframe-body"
|
||
except Exception:
|
||
pass
|
||
except Exception as exc:
|
||
log(f"[Debug] Turnstile shadow 点击失败: {exc}")
|
||
except Exception as exc:
|
||
log(f"[Debug] Turnstile wrapper 路径失败: {exc}")
|
||
|
||
# 2) 页面级 iframe 扫描(优先可见尺寸)
|
||
if not clicked:
|
||
try:
|
||
iframes = page.eles(
|
||
'css:iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], '
|
||
'iframe[title*="Cloudflare"], iframe[title*="Turnstile"]'
|
||
) or []
|
||
# 先点大尺寸的
|
||
ranked = []
|
||
for iframe in iframes[:6]:
|
||
try:
|
||
rect = getattr(iframe, "rect", None)
|
||
w = h = 0
|
||
if rect is not None:
|
||
try:
|
||
w = int(getattr(rect, "size", [0, 0])[0] or 0)
|
||
h = int(getattr(rect, "size", [0, 0])[1] or 0)
|
||
except Exception:
|
||
try:
|
||
w = int(rect.get("width", 0) or 0)
|
||
h = int(rect.get("height", 0) or 0)
|
||
except Exception:
|
||
w = h = 0
|
||
ranked.append((w * h, w, h, iframe))
|
||
except Exception:
|
||
ranked.append((0, 0, 0, iframe))
|
||
ranked.sort(key=lambda x: x[0], reverse=True)
|
||
for area, w, h, iframe in ranked[:3]:
|
||
if not force and area > 0 and (w < 40 or h < 20):
|
||
continue
|
||
try:
|
||
_patch_mouse_screen_coords(iframe)
|
||
try:
|
||
iframe.click()
|
||
clicked = True
|
||
click_path = f"iframe-scan({w}x{h})"
|
||
except Exception:
|
||
pass
|
||
try:
|
||
body = iframe.ele("tag:body", timeout=0.5)
|
||
if body is not None and getattr(body, "shadow_root", None):
|
||
btn = None
|
||
for sel in ("tag:input", "css:input", "css:.mark", "css:label"):
|
||
try:
|
||
btn = body.shadow_root.ele(sel, timeout=0.3)
|
||
except Exception:
|
||
btn = None
|
||
if btn is not None:
|
||
break
|
||
if btn is not None:
|
||
btn.click()
|
||
clicked = True
|
||
click_path = f"iframe-scan-shadow({w}x{h})"
|
||
log("[Debug] Turnstile iframe 扫描点击成功")
|
||
break
|
||
except Exception:
|
||
continue
|
||
if clicked:
|
||
break
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
# 3) JS 兜底:只点可见的 turnstile 容器(不点 0x0)
|
||
if not clicked:
|
||
try:
|
||
js_clicked = page.run_js(
|
||
"""
|
||
function isBig(n){
|
||
if(!n) return false;
|
||
const r = n.getBoundingClientRect();
|
||
return r.width >= 40 && r.height >= 20;
|
||
}
|
||
const nodes = Array.from(document.querySelectorAll('iframe,div,span,label')).filter((n) => {
|
||
const txt = ((n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || '') + ' ' + (n.getAttribute?.('title') || '')).toLowerCase();
|
||
const hit = txt.includes('turnstile') || txt.includes('cloudflare') || txt.includes('cf-');
|
||
return hit && isBig(n);
|
||
});
|
||
if (!nodes.length) return false;
|
||
const el = nodes[0];
|
||
el.scrollIntoView({block:'center', inline:'center'});
|
||
if (typeof el.click === 'function') el.click();
|
||
return true;
|
||
"""
|
||
)
|
||
if js_clicked:
|
||
clicked = True
|
||
click_path = "js-visible-container"
|
||
log("[Debug] Turnstile 可见容器 JS 点击")
|
||
except Exception:
|
||
pass
|
||
|
||
result["clicked"] = bool(clicked)
|
||
result["detail"] = click_path or result["detail"]
|
||
if clicked:
|
||
log(f"[Debug] Turnstile 已点击 path={click_path} mode={result['mode']}")
|
||
elif result["visible"]:
|
||
log(f"[Debug] Turnstile 可见但点击失败 detail={result['detail']}")
|
||
return result
|
||
|
||
|
||
def dismiss_cookie_banner(page, log_callback=None):
|
||
"""关闭隐私/Cookie 横幅,避免挡住 Turnstile。"""
|
||
log = log_callback or (lambda *_: None)
|
||
if page is None:
|
||
return False
|
||
try:
|
||
clicked = page.run_js(
|
||
r"""
|
||
function isVisible(n){
|
||
if(!n) return false;
|
||
const s = getComputedStyle(n);
|
||
if(s.display==='none' || s.visibility==='hidden' || s.opacity==='0') return false;
|
||
const r = n.getBoundingClientRect();
|
||
return r.width > 0 && r.height > 0;
|
||
}
|
||
const labels = ['全部允许','全部拒绝','Allow all','Reject all','Accept all','Accept','同意','允许全部'];
|
||
const nodes = Array.from(document.querySelectorAll('button,a,[role="button"]'));
|
||
for (const lab of labels) {
|
||
const el = nodes.find((n) => {
|
||
if (!isVisible(n) || n.disabled) return false;
|
||
const t = (n.innerText || n.textContent || '').replace(/\s+/g, ' ').trim();
|
||
return t === lab || t.includes(lab);
|
||
});
|
||
if (el) { el.click(); return lab; }
|
||
}
|
||
return '';
|
||
"""
|
||
)
|
||
if clicked:
|
||
log(f"[Debug] 已关闭 Cookie 横幅: {clicked}")
|
||
return True
|
||
except Exception as exc:
|
||
log(f"[Debug] Cookie 横幅处理失败: {exc}")
|
||
return False
|
||
|
||
|
||
def getTurnstileToken(session, log_callback=None, cancel_callback=None, timeout=70):
|
||
"""等待 Turnstile token。
|
||
|
||
策略(按用户反馈):
|
||
- 可见 checkbox:立刻点,不要干等
|
||
- managed/invisible(0x0):先等自动出 token,超时再 force 轻点
|
||
"""
|
||
page = session.page
|
||
if page is None:
|
||
raise Exception("页面未就绪,无法执行 Turnstile")
|
||
|
||
log = log_callback or (lambda *_: None)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
|
||
token = _read_turnstile_token(page)
|
||
if len(token) >= 80:
|
||
log(f"[Debug] Turnstile 已通过,token长度={len(token)}")
|
||
return token
|
||
|
||
deadline = time.time() + max(20, int(timeout))
|
||
managed_force_after = 15.0 # managed 模式等 15s 仍无 token 才 force 点
|
||
visible_click_interval = 4.0
|
||
managed_click_interval = 12.0
|
||
last_click = 0.0
|
||
last_mode = ""
|
||
started = time.time()
|
||
attempts = 0
|
||
visible_clicks = 0
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
attempts += 1
|
||
page = session.page or page
|
||
token = _read_turnstile_token(page)
|
||
if len(token) >= 80:
|
||
log(f"[Debug] Turnstile 已通过,token长度={len(token)}")
|
||
return token
|
||
|
||
state = _inspect_turnstile_state(page)
|
||
mode = state.get("mode") or "none"
|
||
visible = bool(state.get("visible"))
|
||
elapsed = time.time() - started
|
||
now = time.time()
|
||
|
||
if mode != last_mode:
|
||
log(
|
||
f"[Debug] Turnstile 形态变化: {mode} "
|
||
f"size={state.get('max_w')}x{state.get('max_h')} "
|
||
f"iframes={state.get('iframe_count')} detail={state.get('detail')}"
|
||
)
|
||
last_mode = mode
|
||
# 一旦从 managed 变成 visible,立刻点,不再等
|
||
if visible:
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
|
||
last_click = now
|
||
if click_ret.get("clicked"):
|
||
visible_clicks += 1
|
||
|
||
# 可见:高频立刻点(用户反馈「出来了可以点你没点」)
|
||
if visible and (last_click == 0.0 or now - last_click >= visible_click_interval):
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
|
||
last_click = now
|
||
if click_ret.get("clicked"):
|
||
visible_clicks += 1
|
||
# managed:先等;超时后才 force 轻点
|
||
elif (not visible) and elapsed >= managed_force_after and (
|
||
last_click == 0.0 or now - last_click >= managed_click_interval
|
||
):
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
_click_turnstile_checkbox(page, log_callback=log, force=True)
|
||
last_click = now
|
||
|
||
if attempts % 5 == 0:
|
||
log(
|
||
f"[Debug] Turnstile 等待中... elapsed={elapsed:.0f}s "
|
||
f"mode={mode} visible={visible} clicks={visible_clicks} token_len={len(token)}"
|
||
)
|
||
sleep_with_cancel(0.6 if visible else 1.0, cancel_callback)
|
||
|
||
token = _read_turnstile_token(page)
|
||
if len(token) >= 80:
|
||
return token
|
||
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(session, timeout=180, log_callback=None, cancel_callback=None):
|
||
"""填写资料并提交。
|
||
|
||
Turnstile:可见 checkbox 立刻点;managed/invisible 先等,超时再 force 点。
|
||
"""
|
||
page = session.page
|
||
given_name, family_name, password = build_profile()
|
||
deadline = time.time() + timeout
|
||
form_filled = False
|
||
wait_cf_since = None
|
||
last_cf_click = 0.0
|
||
last_cf_mode = ""
|
||
visible_clicks = 0
|
||
log = log_callback or (lambda *_: None)
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
page = session.page or page
|
||
|
||
if not form_filled:
|
||
try:
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
except Exception:
|
||
pass
|
||
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 setInputValue(input, value) {
|
||
if (!input) return false;
|
||
input.focus();
|
||
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('input', { bubbles: true, data: value, inputType: 'insertText' }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return String(input.value || '').trim() === String(value || '').trim();
|
||
}
|
||
function pick(sels) {
|
||
for (const s of sels) {
|
||
const el = Array.from(document.querySelectorAll(s)).find((n) => isVisible(n) && !n.disabled && !n.readOnly);
|
||
if (el) return el;
|
||
}
|
||
return null;
|
||
}
|
||
const givenInput = pick(['input[data-testid="givenName"]','input[name="givenName"]','input[autocomplete="given-name"]','input[aria-label*="名"]']);
|
||
const familyInput = pick(['input[data-testid="familyName"]','input[name="familyName"]','input[autocomplete="family-name"]','input[aria-label*="姓"]']);
|
||
const passwordInput = pick(['input[data-testid="password"]','input[name="password"]','input[type="password"]','input[autocomplete="new-password"]']);
|
||
if (!givenInput || !familyInput || !passwordInput) return 'not-ready';
|
||
if (!setInputValue(givenInput, givenName) || !setInputValue(familyInput, familyName) || !setInputValue(passwordInput, password)) {
|
||
return 'fill-failed';
|
||
}
|
||
return 'filled';
|
||
""",
|
||
given_name,
|
||
family_name,
|
||
password,
|
||
)
|
||
if filled == "filled":
|
||
form_filled = True
|
||
log(f"[Debug] 资料已填写: {given_name} {family_name}")
|
||
wait_cf_since = time.time()
|
||
# 填完立刻探测一次,可见就马上点
|
||
try:
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
state = _inspect_turnstile_state(page)
|
||
if state.get("visible"):
|
||
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
|
||
last_cf_click = time.time()
|
||
last_cf_mode = "visible"
|
||
if click_ret.get("clicked"):
|
||
visible_clicks += 1
|
||
log("[Debug] 资料填完后立刻点击可见 Turnstile")
|
||
else:
|
||
last_cf_mode = state.get("mode") or "managed"
|
||
log(
|
||
f"[Debug] Turnstile 初始形态={last_cf_mode} "
|
||
f"size={state.get('max_w')}x{state.get('max_h')}"
|
||
)
|
||
except Exception as exc:
|
||
log(f"[Debug] 资料填完后 Turnstile 探测失败: {exc}")
|
||
elif filled == "fill-failed":
|
||
log("[Debug] 资料输入失败,重试中...")
|
||
sleep_with_cancel(0.6, cancel_callback)
|
||
else:
|
||
sleep_with_cancel(0.5, cancel_callback)
|
||
continue
|
||
|
||
# 表单已填:等 token;可见 checkbox 立刻点,managed 先等
|
||
token = _read_turnstile_token(page)
|
||
if len(token) < 80:
|
||
elapsed = time.time() - (wait_cf_since or time.time())
|
||
now = time.time()
|
||
state = _inspect_turnstile_state(page)
|
||
mode = state.get("mode") or "none"
|
||
visible = bool(state.get("visible"))
|
||
|
||
if mode != last_cf_mode:
|
||
log(
|
||
f"[Debug] Turnstile 形态={mode} size={state.get('max_w')}x{state.get('max_h')} "
|
||
f"detail={state.get('detail')}"
|
||
)
|
||
last_cf_mode = mode
|
||
if visible:
|
||
try:
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
|
||
last_cf_click = now
|
||
if click_ret.get("clicked"):
|
||
visible_clicks += 1
|
||
log("[Debug] 检测到可见 Turnstile,立即点击")
|
||
except Exception as exc:
|
||
log(f"[Debug] 可见 Turnstile 点击失败: {exc}")
|
||
|
||
# 可见:每 3~4s 点一次,直到出 token
|
||
if visible and (last_cf_click == 0.0 or now - last_cf_click >= 3.5):
|
||
try:
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
click_ret = _click_turnstile_checkbox(page, log_callback=log, force=False)
|
||
last_cf_click = now
|
||
if click_ret.get("clicked"):
|
||
visible_clicks += 1
|
||
except Exception:
|
||
pass
|
||
# managed:15s 后仍无 token 才 force 轻点
|
||
elif (not visible) and elapsed >= 15 and (last_cf_click == 0.0 or now - last_cf_click >= 12):
|
||
try:
|
||
dismiss_cookie_banner(page, log_callback=log)
|
||
ensure_browser_visible_for_turnstile(session, log_callback=log)
|
||
_click_turnstile_checkbox(page, log_callback=log, force=True)
|
||
last_cf_click = now
|
||
log(f"[Debug] managed 超时 force 点击 elapsed={elapsed:.0f}s")
|
||
except Exception:
|
||
pass
|
||
|
||
if int(elapsed) % 5 == 0:
|
||
log(
|
||
f"[Debug] 等待 Cloudflare token... elapsed={elapsed:.0f}s "
|
||
f"mode={mode} visible={visible} clicks={visible_clicks} len={len(token)}"
|
||
)
|
||
sleep_with_cancel(0.6 if visible else 1.0, cancel_callback)
|
||
continue
|
||
|
||
log(f"[Debug] Turnstile token 就绪 len={len(token)},提交注册")
|
||
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;
|
||
}
|
||
function buttonText(node) {
|
||
return [node.innerText, node.textContent, node.getAttribute('value'), node.getAttribute('aria-label')]
|
||
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]'))
|
||
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
|
||
const submitBtn = buttons.find((node) => {
|
||
const t = buttonText(node).replace(/\s+/g, '').toLowerCase();
|
||
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
||
});
|
||
if (!submitBtn) {
|
||
return 'no-submit-button:' + buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
|
||
}
|
||
submitBtn.focus();
|
||
submitBtn.click();
|
||
return 'submitted';
|
||
"""
|
||
)
|
||
if submit_state == "submitted":
|
||
log(f"[Debug] 已填写注册资料并提交: {given_name} {family_name}")
|
||
return {"given_name": given_name, "family_name": family_name, "password": password}
|
||
if isinstance(submit_state, str) and submit_state.startswith("no-submit-button"):
|
||
log(f"[Debug] 未找到提交按钮: {submit_state}")
|
||
sleep_with_cancel(0.8, cancel_callback)
|
||
|
||
raise Exception("最终注册页资料填写失败")
|
||
|
||
|
||
def wait_for_sso_cookie(session, timeout=120, log_callback=None, cancel_callback=None):
|
||
page = session.page
|
||
deadline = time.time() + timeout
|
||
last_seen_names = set()
|
||
last_submit_retry = 0.0
|
||
last_cf_retry_at = 0.0
|
||
final_no_submit_state = ""
|
||
final_no_submit_since = None
|
||
final_no_submit_timeout = 25
|
||
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
session.refresh_page()
|
||
page = session.page
|
||
if page is None:
|
||
sleep_with_cancel(1, cancel_callback)
|
||
continue
|
||
|
||
# 仍停留在“完成注册”页时,若 Cloudflare 已通过,周期性重试点击提交
|
||
now = time.time()
|
||
if now - last_submit_retry >= 2.5:
|
||
retried = page.run_js(
|
||
r"""
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
const titleHit = !!Array.from(document.querySelectorAll('h1,h2,div,span')).find((el) => {
|
||
const t = (el.textContent || '').replace(/\s+/g, '');
|
||
const lower = t.toLowerCase();
|
||
return t.includes('完成注册') || lower.includes('completeyoursignup') || lower.includes('completesignup');
|
||
});
|
||
if (!titleHit) return 'not-final-page';
|
||
|
||
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
||
const cfPresent = !!cfInput
|
||
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
||
if (cfPresent) {
|
||
const token = String((cfInput && cfInput.value) || '').trim();
|
||
const solved = token.length >= 80;
|
||
if (!solved) return 'final-page-wait-cf:' + token.length;
|
||
}
|
||
|
||
function buttonText(node) {
|
||
return [
|
||
node.innerText,
|
||
node.textContent,
|
||
node.getAttribute('value'),
|
||
node.getAttribute('aria-label'),
|
||
node.getAttribute('title'),
|
||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
||
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
const submitBtn = buttons.find((node) => {
|
||
const t = buttonText(node).replace(/\s+/g, '').toLowerCase();
|
||
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
||
});
|
||
if (!submitBtn) {
|
||
const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
|
||
return 'final-page-no-submit:' + visibleTexts;
|
||
}
|
||
submitBtn.focus();
|
||
submitBtn.click();
|
||
return 'final-page-clicked-submit';
|
||
"""
|
||
)
|
||
last_submit_retry = now
|
||
if log_callback and (retried == "final-page-clicked-submit" or (isinstance(retried, str) and retried.startswith("final-page-no-submit"))):
|
||
log_callback(f"[Debug] 最终页状态: {retried}")
|
||
if isinstance(retried, str) and retried.startswith("final-page-no-submit"):
|
||
if retried != final_no_submit_state:
|
||
final_no_submit_state = retried
|
||
final_no_submit_since = now
|
||
elif final_no_submit_since and now - final_no_submit_since >= final_no_submit_timeout:
|
||
raise AccountRetryNeeded(
|
||
f"最终注册页状态 {final_no_submit_timeout}s 未变化且未找到提交按钮,重试当前账号: {retried}"
|
||
)
|
||
else:
|
||
final_no_submit_state = ""
|
||
final_no_submit_since = None
|
||
if log_callback and isinstance(retried, str) and retried.startswith("final-page-wait-cf"):
|
||
token_len = retried.split(":", 1)[1] if ":" in retried else "0"
|
||
log_callback(f"[Debug] 最终页状态: final-page-wait-cf, token长度={token_len}")
|
||
if now - last_cf_retry_at >= 10:
|
||
if log_callback:
|
||
log_callback("[Debug] 最终页 Cloudflare 卡住,自动二次复用 Turnstile...")
|
||
try:
|
||
token = getTurnstileToken(session, 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"[Debug] 最终页 Turnstile 二次复用完成,回填长度={synced}")
|
||
except Exception as cf_exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 最终页 Turnstile 二次复用失败: {cf_exc}")
|
||
last_cf_retry_at = now
|
||
|
||
cookies = page.cookies(all_domains=True, all_info=True) or []
|
||
for item in cookies:
|
||
if isinstance(item, dict):
|
||
name = str(item.get("name", "")).strip()
|
||
value = str(item.get("value", "")).strip()
|
||
else:
|
||
name = str(getattr(item, "name", "")).strip()
|
||
value = str(getattr(item, "value", "")).strip()
|
||
|
||
if name:
|
||
last_seen_names.add(name)
|
||
|
||
if name == "sso" and value:
|
||
if log_callback:
|
||
log_callback("[Debug] 已获取到 sso cookie")
|
||
return value
|
||
except PageDisconnectedError:
|
||
session.refresh_page()
|
||
page = session.page
|
||
except AccountRetryNeeded:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
|
||
sleep_with_cancel(1, cancel_callback)
|
||
|
||
raise Exception(
|
||
f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}"
|
||
)
|
||
|
||
|
||
def cli_log(message):
|
||
text = str(message)
|
||
# [Debug] 细节日志一律不输出,CLI 只保留必要信息
|
||
if "[Debug]" in text:
|
||
return
|
||
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||
print(f"[{timestamp}] {text}", flush=True)
|
||
|
||
|
||
class SharedState:
|
||
"""并发 worker 间共享的状态:名额发放、计数、文件与日志写入,全部加锁。"""
|
||
|
||
def __init__(self, count, accounts_output_file):
|
||
self.count = count
|
||
self.accounts_output_file = accounts_output_file
|
||
self.mail_cred_file = os.path.join(
|
||
os.path.dirname(__file__), "mail_credentials.txt"
|
||
)
|
||
self._issued = 0 # 已发放的名额数(总共发放 count 个)
|
||
self.success_count = 0
|
||
self.fail_count = 0
|
||
self.stop_requested = False
|
||
self._lock = threading.Lock()
|
||
self._io_lock = threading.Lock()
|
||
|
||
def claim_slot(self):
|
||
"""领取一个注册名额,返回 (是否成功, 序号)。"""
|
||
with self._lock:
|
||
if self.stop_requested or self._issued >= self.count:
|
||
return False, 0
|
||
self._issued += 1
|
||
return True, self._issued
|
||
|
||
def record_success(self):
|
||
with self._lock:
|
||
self.success_count += 1
|
||
return self.success_count
|
||
|
||
def record_fail(self):
|
||
with self._lock:
|
||
self.fail_count += 1
|
||
return self.fail_count
|
||
|
||
def should_stop(self):
|
||
return self.stop_requested
|
||
|
||
def stop(self):
|
||
self.stop_requested = True
|
||
|
||
def log(self, worker_id, message):
|
||
with self._io_lock:
|
||
cli_log(f"[W{worker_id}] {message}")
|
||
|
||
def save_account(self, line):
|
||
with self._io_lock:
|
||
try:
|
||
with open(self.accounts_output_file, "a", encoding="utf-8") as f:
|
||
f.write(line)
|
||
except Exception as exc:
|
||
cli_log(f"[Debug] 保存账号文件失败: {exc}")
|
||
|
||
def save_mail_credential(self, email, dev_token):
|
||
with self._io_lock:
|
||
try:
|
||
with open(self.mail_cred_file, "a", encoding="utf-8") as f:
|
||
f.write(f"{email}\t{dev_token}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def register_one(session, shared, worker_id, slot_no):
|
||
"""完成一个账号的完整注册流程(原串行循环体的一次迭代)。"""
|
||
|
||
def log(msg):
|
||
shared.log(worker_id, msg)
|
||
|
||
cancel = shared.should_stop
|
||
email = ""
|
||
dev_token = ""
|
||
code = ""
|
||
mail_ok = False
|
||
max_mail_retry = 3
|
||
for mail_try in range(1, max_mail_retry + 1):
|
||
open_signup_page(session, log_callback=log, cancel_callback=cancel)
|
||
email, dev_token = fill_email_and_submit(
|
||
session, log_callback=log, cancel_callback=cancel
|
||
)
|
||
log(f"[*] 邮箱: {email}")
|
||
shared.save_mail_credential(email, dev_token)
|
||
try:
|
||
code = fill_code_and_submit(
|
||
session, email, dev_token, log_callback=log, cancel_callback=cancel
|
||
)
|
||
mail_ok = True
|
||
break
|
||
except Exception as mail_exc:
|
||
msg = str(mail_exc)
|
||
if ("未收到验证码" in msg or "验证码" in msg) and mail_try < max_mail_retry:
|
||
log(f"[!] 本邮箱未取到验证码,自动更换新邮箱重试: {msg}")
|
||
# 验证码失败通常不是 IP 问题,先同出口重开;仍失败会走外层 rotate
|
||
session.restart(rotate_proxy=False)
|
||
sleep_with_cancel(1, cancel)
|
||
continue
|
||
raise
|
||
|
||
if not mail_ok:
|
||
raise Exception("验证码阶段失败,已达到最大重试次数")
|
||
profile = fill_profile_and_submit(session, log_callback=log, cancel_callback=cancel)
|
||
sso = wait_for_sso_cookie(session, log_callback=log, cancel_callback=cancel)
|
||
if config.get("enable_nsfw", True):
|
||
enable_nsfw_for_token(sso, log_callback=log)
|
||
password = profile.get("password", "")
|
||
shared.save_account(f"{email}----{password}----{sso}\n")
|
||
export_cpa_after_register(
|
||
email, password, session=session, sso=sso, log_callback=log
|
||
)
|
||
return email
|
||
|
||
|
||
def register_worker(worker_id, shared):
|
||
"""单个并发 worker:自建独立浏览器,循环领取名额并注册。"""
|
||
|
||
def log(msg):
|
||
shared.log(worker_id, msg)
|
||
|
||
# 每个 worker 启动时从池取一枚出口
|
||
session = BrowserSession(log_callback=log)
|
||
try:
|
||
session.start(rotate_proxy=True)
|
||
log(f"[Debug] 浏览器已启动 proxy={current_proxy_label()}")
|
||
except Exception as exc:
|
||
log(f"[!] 浏览器启动失败,worker 退出: {exc}")
|
||
return
|
||
|
||
max_slot_retry = 3
|
||
try:
|
||
while not shared.should_stop():
|
||
ok, slot_no = shared.claim_slot()
|
||
if not ok:
|
||
break
|
||
log(
|
||
f"--- 开始第 {slot_no}/{shared.count} 个账号 "
|
||
f"proxy={current_proxy_label()} ---"
|
||
)
|
||
retry_count_for_slot = 0
|
||
while True:
|
||
try:
|
||
# 保持线程代理与 session 一致(HTTP/mint 都读线程绑定)
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.set_thread_proxy(session.proxy)
|
||
except Exception:
|
||
pass
|
||
email = register_one(session, shared, worker_id, slot_no)
|
||
total = shared.record_success()
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.report_success(session.proxy)
|
||
except Exception:
|
||
pass
|
||
log(f"[+] 注册成功: {email}(累计成功 {total})")
|
||
break
|
||
except RegistrationCancelled:
|
||
raise
|
||
except AccountRetryNeeded as exc:
|
||
retry_count_for_slot += 1
|
||
# 卡住:当前出口作废,换代理
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.report_failure(session.proxy, reason=str(exc))
|
||
except Exception:
|
||
pass
|
||
if retry_count_for_slot <= max_slot_retry:
|
||
log(
|
||
f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{max_slot_retry} 次: {exc}"
|
||
)
|
||
session.restart(rotate_proxy=True)
|
||
sleep_with_cancel(1, shared.should_stop)
|
||
continue
|
||
total = shared.record_fail()
|
||
log(f"[-] 当前账号已达到最大重试次数,跳过: {exc}(累计失败 {total})")
|
||
break
|
||
except Exception as exc:
|
||
# 注册页打不开 / 被 x.ai 屏蔽 / 代理隧道失败 → 删库
|
||
try:
|
||
import proxy_pool
|
||
from proxy_manager import is_xai_block_reason
|
||
reason = str(exc)
|
||
if is_xai_block_reason(reason) or any(
|
||
k in reason
|
||
for k in (
|
||
"未找到「使用邮箱注册」",
|
||
"连接已断开",
|
||
"Turnstile",
|
||
"资料填写失败",
|
||
"HTTP Error",
|
||
"abusive",
|
||
"403",
|
||
"Proxy",
|
||
"proxy",
|
||
"ERR_",
|
||
)
|
||
):
|
||
proxy_pool.report_failure(session.proxy, reason=reason)
|
||
except Exception:
|
||
pass
|
||
total = shared.record_fail()
|
||
log(f"[-] 注册失败: {exc}(累计失败 {total})")
|
||
break
|
||
if shared.should_stop():
|
||
break
|
||
# 下一账号:默认轮换出口并重启浏览器
|
||
session.restart()
|
||
except RegistrationCancelled:
|
||
log("[!] 注册被停止")
|
||
except Exception as exc:
|
||
log(f"[!] worker 异常退出: {exc}")
|
||
finally:
|
||
session.stop()
|
||
cleanup_mint_browsers()
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.clear_thread_proxy()
|
||
except Exception:
|
||
pass
|
||
log("[Debug] worker 结束")
|
||
|
||
|
||
def run_registration_concurrent(count, concurrency):
|
||
# 重新装载代理池(防止外部改过 config)
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.configure_from_config(config)
|
||
cli_log(f"[*] 代理: {proxy_pool.status_line()}")
|
||
except Exception as exc:
|
||
cli_log(f"[*] 代理: 装载失败 {exc},走直连/单 proxy")
|
||
|
||
accounts_output_file = os.path.join(
|
||
os.path.dirname(__file__),
|
||
f"accounts_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.txt",
|
||
)
|
||
concurrency = max(1, min(concurrency, count))
|
||
shared = SharedState(count, accounts_output_file)
|
||
cli_log(f"[*] 开始并发注册,目标 {count} 个,并发 {concurrency} 个浏览器")
|
||
cli_log(f"[*] 成功账号将实时保存到: {accounts_output_file}")
|
||
threads = []
|
||
for worker_id in range(concurrency):
|
||
t = threading.Thread(
|
||
target=register_worker, args=(worker_id, shared), daemon=True
|
||
)
|
||
t.start()
|
||
threads.append(t)
|
||
time.sleep(1) # 错开各 worker 启动,降低同一出口 IP 的瞬时并发压力
|
||
try:
|
||
while any(t.is_alive() for t in threads):
|
||
for t in threads:
|
||
t.join(timeout=0.5)
|
||
except KeyboardInterrupt:
|
||
shared.stop()
|
||
cli_log("[!] 收到 Ctrl+C,正在停止所有 worker 并清理...")
|
||
for t in threads:
|
||
t.join()
|
||
cleanup_stray_chrome()
|
||
cli_log(f"[*] 任务结束。成功 {shared.success_count} | 失败 {shared.fail_count}")
|
||
|
||
|
||
def main():
|
||
load_config()
|
||
count = int(config.get("register_count", 1) or 1)
|
||
cli_log("[*] 已加载配置")
|
||
cli_log(
|
||
f"[*] 当前邮箱服务商: {config.get('email_provider', 'duckmail')} | 目标注册数: {count}"
|
||
)
|
||
try:
|
||
import proxy_pool
|
||
proxy_pool.configure_from_config(config)
|
||
cli_log(f"[*] 代理: {proxy_pool.status_line()}")
|
||
except Exception:
|
||
cli_log(f"[*] 代理: {current_proxy_label()}")
|
||
if config.get("cpa_export_enabled", True):
|
||
push = "开" if config.get("cpa_push_enabled", False) else "关"
|
||
cli_log(
|
||
f"[*] CPA 导出: 开启 | 目录: {config.get('cpa_auth_dir', './cpa_auths')} "
|
||
f"| 远端推送: {push}"
|
||
+ (f" -> {config.get('cpa_remote_base')}" if config.get("cpa_push_enabled") else "")
|
||
)
|
||
else:
|
||
cli_log("[*] CPA 导出: 关闭(仅写 accounts_*.txt)")
|
||
try:
|
||
raw = input("请输入并发数量(同时开几个浏览器,直接回车=1): ").strip()
|
||
except (KeyboardInterrupt, EOFError):
|
||
cli_log("[!] 已取消")
|
||
return
|
||
try:
|
||
concurrency = int(raw) if raw else 1
|
||
except ValueError:
|
||
cli_log("[!] 并发数量无效,使用 1")
|
||
concurrency = 1
|
||
if concurrency < 1:
|
||
concurrency = 1
|
||
run_registration_concurrent(count, concurrency)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|