1301 lines
54 KiB
Python
1301 lines
54 KiB
Python
"""Registration browser lifecycle and page automation."""
|
|
import gc
|
|
import random
|
|
import re
|
|
import secrets
|
|
import struct
|
|
import time
|
|
|
|
from DrissionPage import Chromium
|
|
from DrissionPage.errors import PageDisconnectedError
|
|
from curl_cffi import requests
|
|
|
|
browser = None
|
|
page = None
|
|
browser_proxy_bridge = None
|
|
browser_started_with_proxy = False
|
|
cf_clearance = ""
|
|
SIGNUP_URL = "https://accounts.x.ai/sign-up?redirect=grok-com"
|
|
_OWN_NAMES = {'is_cloudflare_block_response', 'response_preview', 'start_browser', 'enable_nsfw_for_token', 'stop_browser_proxy_bridge', 'set_tos_accepted', 'fill_email_and_submit', 'getTurnstileToken', 'set_birth_date', 'generate_random_birthdate', 'fill_profile_and_submit', 'click_email_signup_button', 'wait_for_sso_cookie', 'fill_code_and_submit', 'build_profile', 'cleanup_runtime_memory', 'open_signup_page', 'stop_browser', 'encode_grpc_nsfw_settings', 'restart_browser', 'has_profile_form', 'update_nsfw_settings', 'refresh_active_page'}
|
|
|
|
|
|
def bind_runtime(namespace):
|
|
for name, value in namespace.items():
|
|
if name.startswith("__") or name in _OWN_NAMES or name in {
|
|
"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance",
|
|
}:
|
|
continue
|
|
globals()[name] = value
|
|
|
|
|
|
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:
|
|
if log_callback:
|
|
log_callback(f"[set_birth_date] 异常: {e}")
|
|
return False, f"set_birth_date 异常: {e}"
|
|
|
|
def set_tos_accepted(session, log_callback=None):
|
|
url = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion"
|
|
payload = struct.pack("B", (2 << 3) | 0) + struct.pack("B", 1)
|
|
data = b"\x00" + struct.pack(">I", len(payload)) + payload
|
|
new_headers = {
|
|
"content-type": "application/grpc-web+proto",
|
|
"x-grpc-web": "1",
|
|
"x-user-agent": "connect-es/2.1.1",
|
|
"origin": "https://accounts.x.ai",
|
|
"referer": "https://accounts.x.ai/accept-tos",
|
|
}
|
|
try:
|
|
res = session.post(url, data=data, headers=new_headers, timeout=15)
|
|
if log_callback:
|
|
log_callback(f"[Debug] set_tos_accepted status: {res.status_code}")
|
|
if 200 <= res.status_code < 300:
|
|
return True, "ok"
|
|
if is_cloudflare_block_response(res):
|
|
return (
|
|
False,
|
|
"set_tos_accepted 被 accounts.x.ai 的 Cloudflare 防护拦截,HTTP "
|
|
f"{res.status_code}",
|
|
)
|
|
return False, f"set_tos_accepted HTTP {res.status_code}: {response_preview(res)}"
|
|
except Exception as e:
|
|
if log_callback:
|
|
log_callback(f"[set_tos_accepted] 异常: {e}")
|
|
return False, f"set_tos_accepted 异常: {e}"
|
|
|
|
def encode_grpc_nsfw_settings():
|
|
field1_content = bytes([0x10, 0x01])
|
|
field1 = bytes([0x0A, len(field1_content)]) + field1_content
|
|
nsfw_string = b"always_show_nsfw_content"
|
|
field2_inner = bytes([0x0A, len(nsfw_string)]) + nsfw_string
|
|
field2 = bytes([0x12, len(field2_inner)]) + field2_inner
|
|
payload = field1 + field2
|
|
return b"\x00" + struct.pack(">I", len(payload)) + payload
|
|
|
|
def update_nsfw_settings(session, log_callback=None):
|
|
url = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls"
|
|
data = encode_grpc_nsfw_settings()
|
|
new_headers = {
|
|
"content-type": "application/grpc-web+proto",
|
|
"x-grpc-web": "1",
|
|
"origin": "https://grok.com",
|
|
"referer": "https://grok.com/",
|
|
}
|
|
try:
|
|
res = session.post(url, data=data, headers=new_headers, timeout=15)
|
|
if log_callback:
|
|
log_callback(
|
|
f"[Debug] update_nsfw status: {res.status_code}, body: {response_preview(res)}"
|
|
)
|
|
if 200 <= res.status_code < 300:
|
|
return True, "ok"
|
|
if is_cloudflare_block_response(res):
|
|
return (
|
|
False,
|
|
"update_nsfw_settings 被 grok.com 的 Cloudflare 防护拦截,HTTP "
|
|
f"{res.status_code}",
|
|
)
|
|
return False, f"update_nsfw_settings HTTP {res.status_code}: {response_preview(res)}"
|
|
except Exception as e:
|
|
if log_callback:
|
|
log_callback(f"[update_nsfw] 异常: {e}")
|
|
return False, f"update_nsfw_settings 异常: {e}"
|
|
|
|
def enable_nsfw_for_token(token, cf_clearance="", log_callback=None):
|
|
proxies = get_proxies()
|
|
user_agent = get_user_agent()
|
|
try:
|
|
with requests.Session(impersonate="chrome120", proxies=proxies) as session:
|
|
cookie_parts = [f"sso={token}", f"sso-rw={token}"]
|
|
if cf_clearance:
|
|
cookie_parts.append(f"cf_clearance={cf_clearance}")
|
|
session.headers.update(
|
|
{
|
|
"user-agent": 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"
|
|
except Exception as e:
|
|
return False, f"异常: {str(e)}"
|
|
|
|
def stop_browser_proxy_bridge():
|
|
global browser_proxy_bridge
|
|
if browser_proxy_bridge is not None:
|
|
try:
|
|
browser_proxy_bridge.stop()
|
|
except Exception:
|
|
pass
|
|
browser_proxy_bridge = None
|
|
|
|
def start_browser(log_callback=None, use_proxy=True):
|
|
global browser, page, browser_proxy_bridge, browser_started_with_proxy
|
|
last_exc = None
|
|
proxy_enabled = bool(use_proxy and get_configured_proxy())
|
|
for attempt in range(1, 5):
|
|
bridge = None
|
|
try:
|
|
browser_proxy, bridge = prepare_browser_proxy(use_proxy=use_proxy, log_callback=log_callback)
|
|
browser = Chromium(create_browser_options(browser_proxy=browser_proxy))
|
|
browser_proxy_bridge = bridge
|
|
browser_started_with_proxy = bool(browser_proxy)
|
|
tabs = browser.get_tabs()
|
|
page = tabs[-1] if tabs else browser.new_tab()
|
|
if log_callback and getattr(browser, "user_data_path", None):
|
|
log_callback(f"[Debug] 当前浏览器资料目录: {browser.user_data_path}")
|
|
if log_callback and get_configured_proxy():
|
|
mode = "代理" if browser_started_with_proxy else "直连"
|
|
log_callback(f"[*] 浏览器网络模式: {mode}")
|
|
if log_callback and attempt > 1:
|
|
log_callback(f"[*] 浏览器第 {attempt} 次启动成功")
|
|
return browser, page
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if bridge is not None:
|
|
try:
|
|
bridge.stop()
|
|
except Exception:
|
|
pass
|
|
if log_callback:
|
|
mode = "代理" if proxy_enabled else "直连"
|
|
log_callback(f"[Debug] 浏览器{mode}启动失败(第{attempt}/4次): {exc}")
|
|
try:
|
|
if browser is not None:
|
|
browser.quit(del_data=True)
|
|
except Exception:
|
|
pass
|
|
browser = None
|
|
page = None
|
|
browser_proxy_bridge = None
|
|
browser_started_with_proxy = False
|
|
time.sleep(min(1.5 * attempt, 4))
|
|
raise Exception(f"浏览器启动失败,已重试4次: {last_exc}")
|
|
|
|
def stop_browser():
|
|
global browser, page, browser_started_with_proxy
|
|
if browser is not None:
|
|
try:
|
|
browser.quit(del_data=True)
|
|
except Exception:
|
|
pass
|
|
stop_browser_proxy_bridge()
|
|
browser = None
|
|
page = None
|
|
browser_started_with_proxy = False
|
|
|
|
def restart_browser(log_callback=None, use_proxy=True):
|
|
stop_browser()
|
|
return start_browser(log_callback=log_callback, use_proxy=use_proxy)
|
|
|
|
def cleanup_runtime_memory(log_callback=None, reason="定期清理"):
|
|
if log_callback:
|
|
log_callback(f"[*] {reason}: 关闭浏览器并清理内存")
|
|
stop_browser()
|
|
try:
|
|
from cpa_xai.browser_confirm import shutdown_mint_browsers
|
|
shutdown_mint_browsers()
|
|
except Exception as exc:
|
|
if log_callback:
|
|
log_callback(f"[Debug] CPA 浏览器清理失败: {exc}")
|
|
collected = gc.collect()
|
|
if log_callback:
|
|
log_callback(f"[*] Python GC 已回收对象数: {collected}")
|
|
|
|
def refresh_active_page():
|
|
global browser, page
|
|
if browser is None:
|
|
restart_browser()
|
|
try:
|
|
tabs = browser.get_tabs()
|
|
if tabs:
|
|
page = tabs[-1]
|
|
else:
|
|
page = browser.new_tab()
|
|
except Exception:
|
|
restart_browser()
|
|
return page
|
|
|
|
def click_email_signup_button(timeout=10, log_callback=None, cancel_callback=None):
|
|
global page
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
raise_if_cancelled(cancel_callback)
|
|
if log_callback:
|
|
log_callback("[Debug] 尝试查找“使用邮箱注册”按钮...")
|
|
|
|
clicked = page.run_js(r"""
|
|
function isVisible(node) {
|
|
if (!node) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
function nodeText(node) {
|
|
return [
|
|
node.innerText,
|
|
node.textContent,
|
|
node.getAttribute('aria-label'),
|
|
node.getAttribute('title'),
|
|
node.getAttribute('href'),
|
|
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
function scoreEntry(node) {
|
|
const compact = nodeText(node).replace(/\s+/g, '');
|
|
const lower = compact.toLowerCase();
|
|
if (compact.includes('使用邮箱注册')) return 100;
|
|
if (lower.includes('signupwithemail')) return 95;
|
|
if (lower.includes('continuewithemail')) return 90;
|
|
if (lower.includes('email') && (lower.includes('sign') || lower.includes('continue') || lower.includes('use') || lower.includes('with'))) return 80;
|
|
if (lower === 'email' || lower.includes('邮箱')) return 70;
|
|
return 0;
|
|
}
|
|
const candidates = Array.from(document.querySelectorAll('button, a, [role="button"]'))
|
|
.filter((node) => isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true')
|
|
.map((node) => ({ node, score: scoreEntry(node), text: nodeText(node) }))
|
|
.filter((item) => item.score > 0)
|
|
.sort((a, b) => b.score - a.score);
|
|
const target = candidates[0]?.node || null;
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
target.click();
|
|
return candidates[0].text || true;
|
|
""")
|
|
|
|
if clicked:
|
|
if log_callback:
|
|
detail = f": {clicked}" if isinstance(clicked, str) else ""
|
|
log_callback(f"[*] 已点击「使用邮箱注册」按钮{detail}")
|
|
sleep_with_cancel(2, cancel_callback)
|
|
return True
|
|
|
|
if log_callback:
|
|
current_url = page.url if page else "none"
|
|
log_callback(f"[Debug] 当前URL: {current_url}")
|
|
|
|
sleep_with_cancel(1, cancel_callback)
|
|
|
|
if log_callback:
|
|
page_html = page.html[:500] if page else "no page"
|
|
log_callback(f"[Debug] 页面内容片段: {page_html}")
|
|
|
|
raise Exception("未找到「使用邮箱注册」按钮")
|
|
|
|
def open_signup_page(log_callback=None, cancel_callback=None):
|
|
global browser, page
|
|
raise_if_cancelled(cancel_callback)
|
|
if browser is None:
|
|
start_browser(log_callback=log_callback)
|
|
if log_callback:
|
|
log_callback("[*] 浏览器已启动")
|
|
|
|
def _open_with_current_browser():
|
|
global page
|
|
try:
|
|
page = browser.get_tab(0)
|
|
page.get(SIGNUP_URL)
|
|
except Exception as e:
|
|
if log_callback:
|
|
log_callback(f"[Debug] 打开URL异常: {e}")
|
|
page = browser.new_tab(SIGNUP_URL)
|
|
page.wait.doc_loaded()
|
|
|
|
try:
|
|
_open_with_current_browser()
|
|
except Exception as e:
|
|
if browser_started_with_proxy and get_configured_proxy():
|
|
if log_callback:
|
|
log_callback(f"[!] 浏览器代理访问注册页失败,自动回退直连: {e}")
|
|
restart_browser(log_callback=log_callback, use_proxy=False)
|
|
_open_with_current_browser()
|
|
else:
|
|
raise
|
|
|
|
if browser_started_with_proxy and page_has_proxy_error(page):
|
|
if log_callback:
|
|
log_callback("[!] 浏览器页面显示代理错误,自动回退直连")
|
|
restart_browser(log_callback=log_callback, use_proxy=False)
|
|
_open_with_current_browser()
|
|
|
|
sleep_with_cancel(2, cancel_callback)
|
|
if log_callback:
|
|
log_callback(f"[*] 当前URL: {page.url}")
|
|
click_email_signup_button(
|
|
log_callback=log_callback, cancel_callback=cancel_callback
|
|
)
|
|
|
|
def has_profile_form(log_callback=None):
|
|
refresh_active_page()
|
|
try:
|
|
return bool(
|
|
page.run_js(
|
|
"""
|
|
const givenInput = document.querySelector('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"]');
|
|
const familyInput = document.querySelector('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"]');
|
|
const passwordInput = document.querySelector('input[data-testid="password"], input[name="password"], input[type="password"]');
|
|
return !!(givenInput && familyInput && passwordInput);
|
|
"""
|
|
)
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
def fill_email_and_submit(timeout=45, log_callback=None, cancel_callback=None):
|
|
raise_if_cancelled(cancel_callback)
|
|
email, dev_token = get_email_and_token()
|
|
if not email or not dev_token:
|
|
raise Exception("获取邮箱失败")
|
|
if log_callback:
|
|
log_callback(f"[*] 已创建邮箱: {email}")
|
|
deadline = time.time() + timeout
|
|
last_diag_time = 0
|
|
last_reclick_time = 0
|
|
last_snapshot = None
|
|
while time.time() < deadline:
|
|
raise_if_cancelled(cancel_callback)
|
|
filled = page.run_js(
|
|
"""
|
|
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"[*] 已填写邮箱并提交: {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(email, dev_token, timeout=180, log_callback=None, cancel_callback=None):
|
|
def _resend_code():
|
|
page.run_js(
|
|
r"""
|
|
const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]'));
|
|
const target = nodes.find((node) => {
|
|
const t = (node.innerText || node.textContent || '').replace(/\s+/g, '').toLowerCase();
|
|
return t.includes('重新发送') || t.includes('resend') || t.includes('再次发送');
|
|
});
|
|
if (target && !target.disabled) { target.click(); return true; }
|
|
return false;
|
|
"""
|
|
)
|
|
|
|
code = get_oai_code(
|
|
dev_token,
|
|
email,
|
|
log_callback=log_callback,
|
|
cancel_callback=cancel_callback,
|
|
resend_callback=_resend_code,
|
|
)
|
|
if not code:
|
|
raise Exception("获取验证码失败")
|
|
clean_code = str(code).replace("-", "").strip()
|
|
deadline = time.time() + timeout
|
|
|
|
while time.time() < deadline:
|
|
raise_if_cancelled(cancel_callback)
|
|
filled = page.run_js(
|
|
"""
|
|
const code = String(arguments[0] || '').trim();
|
|
if (!code) return 'empty-code';
|
|
|
|
function isVisible(node) {
|
|
if (!node) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
function setInputValue(input, value) {
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
|
const tracker = input._valueTracker;
|
|
if (tracker) tracker.setValue('');
|
|
if (nativeSetter) nativeSetter.call(input, value);
|
|
else input.value = value;
|
|
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' }));
|
|
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
const aggregate = Array.from(document.querySelectorAll(
|
|
'input[data-input-otp=\"true\"], input[name=\"code\"], input[autocomplete=\"one-time-code\"], input[inputmode=\"numeric\"], input[inputmode=\"text\"]'
|
|
)).find((node) => isVisible(node) && !node.disabled && !node.readOnly && Number(node.maxLength || 6) > 1);
|
|
|
|
if (aggregate) {
|
|
aggregate.focus();
|
|
aggregate.click();
|
|
setInputValue(aggregate, code);
|
|
return String(aggregate.value || '').replace(/\\s+/g, '') ? 'filled-aggregate' : 'aggregate-failed';
|
|
}
|
|
|
|
const otpBoxes = Array.from(document.querySelectorAll('input')).filter((node) => {
|
|
if (!isVisible(node) || node.disabled || node.readOnly) return false;
|
|
const maxLength = Number(node.maxLength || 0);
|
|
const ac = String(node.autocomplete || '').toLowerCase();
|
|
return maxLength === 1 || ac === 'one-time-code';
|
|
});
|
|
|
|
if (otpBoxes.length >= code.length) {
|
|
for (let i = 0; i < code.length; i += 1) {
|
|
const ch = code[i] || '';
|
|
const box = otpBoxes[i];
|
|
box.focus();
|
|
box.click();
|
|
setInputValue(box, ch);
|
|
box.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }));
|
|
box.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }));
|
|
}
|
|
const merged = otpBoxes.slice(0, code.length).map((x) => String(x.value || '').trim()).join('');
|
|
return merged.length ? 'filled-boxes' : 'boxes-failed';
|
|
}
|
|
|
|
return 'not-ready';
|
|
""",
|
|
clean_code,
|
|
)
|
|
|
|
if filled == "not-ready":
|
|
sleep_with_cancel(0.5, cancel_callback)
|
|
continue
|
|
if "failed" in str(filled):
|
|
if log_callback:
|
|
log_callback(f"[Debug] 验证码填写失败: {filled}")
|
|
sleep_with_cancel(0.5, cancel_callback)
|
|
continue
|
|
|
|
clicked = page.run_js(
|
|
r"""
|
|
function isVisible(node) {
|
|
if (!node) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
const buttons = Array.from(document.querySelectorAll('button[type=\"submit\"], button')).filter((node) => {
|
|
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
|
});
|
|
|
|
const btn = buttons.find((node) => {
|
|
const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase();
|
|
return (
|
|
t.includes('确认邮箱') ||
|
|
t.includes('继续') ||
|
|
t.includes('下一步') ||
|
|
t.includes('confirm') ||
|
|
t.includes('continue') ||
|
|
t.includes('next')
|
|
);
|
|
});
|
|
|
|
if (!btn) return 'no-button';
|
|
btn.focus();
|
|
btn.click();
|
|
return 'clicked';
|
|
"""
|
|
)
|
|
|
|
if clicked == "clicked" or clicked == "no-button":
|
|
if log_callback:
|
|
log_callback(f"[*] 已填写验证码并提交: {code}")
|
|
sleep_with_cancel(1.5, cancel_callback)
|
|
return code
|
|
|
|
sleep_with_cancel(0.5, cancel_callback)
|
|
|
|
raise Exception("验证码已获取,但自动填写/提交失败")
|
|
|
|
def getTurnstileToken(log_callback=None, cancel_callback=None):
|
|
global page
|
|
if page is None:
|
|
raise Exception("页面未就绪,无法执行 Turnstile")
|
|
|
|
try:
|
|
page.run_js(
|
|
"try { if (window.turnstile && typeof turnstile.reset === 'function') turnstile.reset(); } catch(e) {}"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
for _ in range(0, 20):
|
|
raise_if_cancelled(cancel_callback)
|
|
try:
|
|
token = page.run_js(
|
|
"""
|
|
try {
|
|
const byInput = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim();
|
|
if (byInput) return byInput;
|
|
if (window.turnstile && typeof turnstile.getResponse === 'function') {
|
|
return String(turnstile.getResponse() || '').trim();
|
|
}
|
|
return '';
|
|
} catch(e) { return ''; }
|
|
"""
|
|
)
|
|
token = str(token or "").strip()
|
|
if len(token) >= 80:
|
|
if log_callback:
|
|
log_callback(f"[*] Turnstile 已通过,token长度={len(token)}")
|
|
return token
|
|
|
|
challenge_input = page.ele("@name=cf-turnstile-response")
|
|
if challenge_input:
|
|
wrapper = challenge_input.parent()
|
|
iframe = None
|
|
try:
|
|
iframe = wrapper.shadow_root.ele("tag:iframe")
|
|
except Exception:
|
|
iframe = None
|
|
if iframe:
|
|
try:
|
|
iframe.run_js(
|
|
"""
|
|
window.dtp = 1;
|
|
function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
|
let sx = getRandomInt(800, 1200);
|
|
let sy = getRandomInt(400, 700);
|
|
Object.defineProperty(MouseEvent.prototype, 'screenX', { value: sx });
|
|
Object.defineProperty(MouseEvent.prototype, 'screenY', { value: sy });
|
|
"""
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
body_sr = iframe.ele("tag:body").shadow_root
|
|
btn = body_sr.ele("tag:input")
|
|
if btn:
|
|
btn.click()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# 兜底:尝试触发页面上可见的 Turnstile 容器
|
|
page.run_js(
|
|
"""
|
|
const nodes = Array.from(document.querySelectorAll('div,span,iframe')).filter((n) => {
|
|
const txt = (n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || '');
|
|
return String(txt).toLowerCase().includes('turnstile');
|
|
});
|
|
if (nodes.length && typeof nodes[0].click === 'function') nodes[0].click();
|
|
"""
|
|
)
|
|
except Exception:
|
|
pass
|
|
sleep_with_cancel(1, cancel_callback)
|
|
|
|
raise Exception("Turnstile 获取 token 失败")
|
|
|
|
def build_profile():
|
|
given_name_pool = [
|
|
"Neo", "Ethan", "Liam", "Noah", "Lucas", "Mason", "Ryan", "Leo",
|
|
"Owen", "Aiden", "Elio", "Aron", "Ivan", "Nolan", "Evan", "Kai",
|
|
"Caleb", "Adam", "Ezra", "Miles", "Logan", "Carter", "Hunter", "Jason",
|
|
"Brian", "Dylan", "Alex", "Colin", "Blake", "Gavin", "Henry", "Julian",
|
|
"Kevin", "Louis", "Marcus", "Nathan", "Oscar", "Peter", "Quinn", "Robin",
|
|
"Simon", "Tristan", "Victor", "Wesley", "Xavier", "Yuri", "Zane", "Felix",
|
|
"Aaron", "Damian",
|
|
]
|
|
family_name_pool = [
|
|
"Lin", "Wang", "Zhao", "Liu", "Chen", "Zhang", "Xu", "Sun",
|
|
"Guo", "He", "Yang", "Wu", "Zhou", "Tang", "Qin", "Shi",
|
|
"Fang", "Peng", "Cao", "Deng", "Fan", "Fu", "Gao", "Han",
|
|
"Hu", "Jiang", "Kong", "Lu", "Ma", "Nie", "Pan", "Qiao",
|
|
"Ren", "Shao", "Tian", "Xie", "Yan", "Yao", "Yu", "Zeng",
|
|
"Bai", "Duan", "Hou", "Jin", "Kang", "Luo", "Mao", "Song",
|
|
"Wei", "Xiong",
|
|
]
|
|
given_name = random.choice(given_name_pool)
|
|
family_name = random.choice(family_name_pool)
|
|
password = "N" + secrets.token_hex(4) + "!a7#" + secrets.token_urlsafe(6)
|
|
return given_name, family_name, password
|
|
|
|
def fill_profile_and_submit(timeout=120, log_callback=None, cancel_callback=None):
|
|
given_name, family_name, password = build_profile()
|
|
deadline = time.time() + timeout
|
|
form_filled_once = False
|
|
wait_cf_since = None
|
|
last_cf_retry_at = 0.0
|
|
|
|
while time.time() < deadline:
|
|
raise_if_cancelled(cancel_callback)
|
|
if not form_filled_once:
|
|
filled = page.run_js(
|
|
"""
|
|
const givenName = arguments[0];
|
|
const familyName = arguments[1];
|
|
const password = arguments[2];
|
|
|
|
function isVisible(node) {
|
|
if (!node) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
function pickInput(selector) {
|
|
return Array.from(document.querySelectorAll(selector)).find((node) => {
|
|
return isVisible(node) && !node.disabled && !node.readOnly;
|
|
}) || null;
|
|
}
|
|
|
|
function setInputValue(input, value) {
|
|
if (!input) return false;
|
|
input.focus();
|
|
input.click();
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
|
const tracker = input._valueTracker;
|
|
if (tracker) tracker.setValue('');
|
|
if (nativeSetter) nativeSetter.call(input, value);
|
|
else input.value = value;
|
|
input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' }));
|
|
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
input.blur();
|
|
return String(input.value || '').trim() === String(value || '').trim();
|
|
}
|
|
|
|
const givenInput = pickInput('input[data-testid="givenName"], input[name="givenName"], input[autocomplete="given-name"], input[aria-label*="名"]');
|
|
const familyInput = pickInput('input[data-testid="familyName"], input[name="familyName"], input[autocomplete="family-name"], input[aria-label*="姓"]');
|
|
const passwordInput = pickInput('input[data-testid="password"], input[name="password"], input[type="password"], input[autocomplete="new-password"]');
|
|
|
|
if (!givenInput || !familyInput || !passwordInput) return 'not-ready';
|
|
|
|
const ok1 = setInputValue(givenInput, givenName);
|
|
const ok2 = setInputValue(familyInput, familyName);
|
|
const ok3 = setInputValue(passwordInput, password);
|
|
|
|
if (!ok1 || !ok2 || !ok3) return 'fill-failed';
|
|
|
|
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
|
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
|
});
|
|
const submitBtn = buttons.find((node) => {
|
|
const t = (node.innerText || node.textContent || '').replace(/\\s+/g, '').toLowerCase();
|
|
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
|
});
|
|
|
|
// 必须等待 Cloudflare 校验通过后再提交
|
|
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
|
const cfPresent = !!cfInput
|
|
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
|
if (cfPresent) {
|
|
const token = String((cfInput && cfInput.value) || '').trim();
|
|
const solvedByToken = token.length >= 80;
|
|
if (!solvedByToken) return 'wait-cloudflare:' + token.length;
|
|
}
|
|
|
|
if (submitBtn) {
|
|
return 'ready-to-submit';
|
|
}
|
|
return 'filled-no-submit';
|
|
""",
|
|
given_name,
|
|
family_name,
|
|
password,
|
|
)
|
|
|
|
if isinstance(filled, str) and filled.startswith("wait-cloudflare"):
|
|
form_filled_once = True
|
|
if log_callback:
|
|
token_len = filled.split(":", 1)[1] if ":" in filled else "0"
|
|
log_callback(f"[*] 资料已填写,等待 Cloudflare 人机验证通过... 当前token长度={token_len}")
|
|
if token_len == "0":
|
|
pause_seconds = random.uniform(1, 3)
|
|
if log_callback:
|
|
log_callback(f"[*] Cloudflare token 为空,暂停 {pause_seconds:.1f}s 后继续检测")
|
|
sleep_with_cancel(pause_seconds, cancel_callback)
|
|
now = time.time()
|
|
if wait_cf_since is None:
|
|
wait_cf_since = now
|
|
# 卡住后自动二次复用 Turnstile 组件
|
|
if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8:
|
|
if log_callback:
|
|
log_callback("[*] Cloudflare 验证卡住,开始二次复用 Turnstile...")
|
|
try:
|
|
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
|
|
if token:
|
|
synced = page.run_js(
|
|
"""
|
|
const token = String(arguments[0] || '').trim();
|
|
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
|
if (!cfInput || !token) return false;
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
|
if (nativeSetter) nativeSetter.call(cfInput, token);
|
|
else cfInput.value = token;
|
|
cfInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
cfInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return String(cfInput.value || '').trim().length;
|
|
""",
|
|
token,
|
|
)
|
|
if log_callback:
|
|
log_callback(f"[*] Turnstile 二次复用完成,回填长度={synced}")
|
|
except Exception as cf_exc:
|
|
if log_callback:
|
|
log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}")
|
|
last_cf_retry_at = now
|
|
sleep_with_cancel(0.8, cancel_callback)
|
|
continue
|
|
|
|
if filled in ("ready-to-submit", "filled-no-submit"):
|
|
form_filled_once = True
|
|
elif filled == "fill-failed" and log_callback:
|
|
log_callback("[Debug] 资料输入失败,重试中...")
|
|
sleep_with_cancel(0.5, cancel_callback)
|
|
continue
|
|
elif filled == "not-ready":
|
|
sleep_with_cancel(0.5, cancel_callback)
|
|
continue
|
|
|
|
submit_state = page.run_js(
|
|
r"""
|
|
function isVisible(node) {
|
|
if (!node) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
|
const cfPresent = !!cfInput
|
|
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
|
if (cfPresent) {
|
|
const token = String((cfInput && cfInput.value) || '').trim();
|
|
const solvedByToken = token.length >= 80;
|
|
if (!solvedByToken) return 'wait-cloudflare:' + token.length;
|
|
}
|
|
|
|
function buttonText(node) {
|
|
return [
|
|
node.innerText,
|
|
node.textContent,
|
|
node.getAttribute('value'),
|
|
node.getAttribute('aria-label'),
|
|
node.getAttribute('title'),
|
|
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
|
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
|
});
|
|
const submitBtn = buttons.find((node) => {
|
|
const t = buttonText(node).replace(/\s+/g, '').toLowerCase();
|
|
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
|
});
|
|
if (!submitBtn) {
|
|
const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
|
|
return 'no-submit-button:' + visibleTexts;
|
|
}
|
|
submitBtn.focus();
|
|
submitBtn.click();
|
|
return 'submitted';
|
|
"""
|
|
)
|
|
|
|
if isinstance(submit_state, str) and submit_state.startswith("wait-cloudflare"):
|
|
if log_callback:
|
|
token_len = submit_state.split(":", 1)[1] if ":" in submit_state else "0"
|
|
log_callback(f"[*] 等待 Cloudflare 人机验证通过后再提交... 当前token长度={token_len}")
|
|
now = time.time()
|
|
if wait_cf_since is None:
|
|
wait_cf_since = now
|
|
if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8:
|
|
if log_callback:
|
|
log_callback("[*] 提交前仍卡住,自动再次复用 Turnstile...")
|
|
try:
|
|
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
|
|
if token:
|
|
synced = page.run_js(
|
|
"""
|
|
const token = String(arguments[0] || '').trim();
|
|
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
|
if (!cfInput || !token) return false;
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
|
if (nativeSetter) nativeSetter.call(cfInput, token);
|
|
else cfInput.value = token;
|
|
cfInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
cfInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return String(cfInput.value || '').trim().length;
|
|
""",
|
|
token,
|
|
)
|
|
if log_callback:
|
|
log_callback(f"[*] Turnstile 二次复用完成,回填长度={synced}")
|
|
except Exception as cf_exc:
|
|
if log_callback:
|
|
log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}")
|
|
last_cf_retry_at = now
|
|
sleep_with_cancel(0.8, cancel_callback)
|
|
continue
|
|
|
|
if submit_state == "submitted":
|
|
if log_callback:
|
|
log_callback(f"[*] 已填写注册资料并提交: {given_name} {family_name}")
|
|
return {"given_name": given_name, "family_name": family_name, "password": password}
|
|
wait_cf_since = None
|
|
if isinstance(submit_state, str) and submit_state.startswith("no-submit-button") and log_callback:
|
|
visible_buttons = submit_state.split(":", 1)[1] if ":" in submit_state else ""
|
|
suffix = f" 可见按钮: {visible_buttons}" if visible_buttons else ""
|
|
log_callback(f"[Debug] 未找到提交按钮,继续等待页面稳定...{suffix}")
|
|
|
|
sleep_with_cancel(0.5, cancel_callback)
|
|
|
|
raise Exception("最终注册页资料填写失败")
|
|
|
|
def wait_for_sso_cookie(timeout=120, log_callback=None, cancel_callback=None):
|
|
deadline = time.time() + timeout
|
|
last_seen_names = set()
|
|
last_submit_retry = 0.0
|
|
last_cf_retry_at = 0.0
|
|
final_no_submit_state = ""
|
|
final_no_submit_since = None
|
|
final_no_submit_timeout = 25
|
|
last_wait_exception_message = ""
|
|
last_wait_exception_at = 0.0
|
|
|
|
while time.time() < deadline:
|
|
raise_if_cancelled(cancel_callback)
|
|
try:
|
|
refresh_active_page()
|
|
if page is None:
|
|
sleep_with_cancel(1, cancel_callback)
|
|
continue
|
|
|
|
# 仍停留在“完成注册”页时,若 Cloudflare 已通过,周期性重试点击提交
|
|
now = time.time()
|
|
if now - last_submit_retry >= 2.5:
|
|
retried = page.run_js(
|
|
r"""
|
|
function isVisible(node) {
|
|
if (!node) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
const titleHit = !!Array.from(document.querySelectorAll('h1,h2,div,span')).find((el) => {
|
|
const t = (el.textContent || '').replace(/\s+/g, '');
|
|
const lower = t.toLowerCase();
|
|
return t.includes('完成注册') || lower.includes('completeyoursignup') || lower.includes('completesignup');
|
|
});
|
|
if (!titleHit) return 'not-final-page';
|
|
|
|
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
|
const cfPresent = !!cfInput
|
|
|| !!document.querySelector('iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey], script[src*="turnstile"]');
|
|
if (cfPresent) {
|
|
const token = String((cfInput && cfInput.value) || '').trim();
|
|
const solved = token.length >= 80;
|
|
if (!solved) return 'final-page-wait-cf:' + token.length;
|
|
}
|
|
|
|
function buttonText(node) {
|
|
return [
|
|
node.innerText,
|
|
node.textContent,
|
|
node.getAttribute('value'),
|
|
node.getAttribute('aria-label'),
|
|
node.getAttribute('title'),
|
|
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
const buttons = Array.from(document.querySelectorAll('button[type="submit"], button, [role="button"], input[type="submit"]')).filter((node) => {
|
|
return isVisible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true';
|
|
});
|
|
const submitBtn = buttons.find((node) => {
|
|
const t = buttonText(node).replace(/\s+/g, '').toLowerCase();
|
|
return t.includes('完成注册') || t.includes('创建账户') || t.includes('signup') || t.includes('createaccount');
|
|
});
|
|
if (!submitBtn) {
|
|
const visibleTexts = buttons.map(buttonText).filter(Boolean).slice(0, 8).join(' | ');
|
|
return 'final-page-no-submit:' + visibleTexts;
|
|
}
|
|
submitBtn.focus();
|
|
submitBtn.click();
|
|
return 'final-page-clicked-submit';
|
|
"""
|
|
)
|
|
last_submit_retry = now
|
|
if log_callback and (retried == "final-page-clicked-submit" or (isinstance(retried, str) and retried.startswith("final-page-no-submit"))):
|
|
log_callback(f"[Debug] 最终页状态: {retried}")
|
|
if isinstance(retried, str) and retried.startswith("final-page-no-submit"):
|
|
if retried != final_no_submit_state:
|
|
final_no_submit_state = retried
|
|
final_no_submit_since = now
|
|
elif final_no_submit_since and now - final_no_submit_since >= final_no_submit_timeout:
|
|
raise AccountRetryNeeded(
|
|
f"最终注册页状态 {final_no_submit_timeout}s 未变化且未找到提交按钮,重试当前账号: {retried}"
|
|
)
|
|
else:
|
|
final_no_submit_state = ""
|
|
final_no_submit_since = None
|
|
if log_callback and isinstance(retried, str) and retried.startswith("final-page-wait-cf"):
|
|
token_len = retried.split(":", 1)[1] if ":" in retried else "0"
|
|
log_callback(f"[Debug] 最终页状态: final-page-wait-cf, token长度={token_len}")
|
|
if now - last_cf_retry_at >= 10:
|
|
if log_callback:
|
|
log_callback("[*] 最终页 Cloudflare 卡住,自动二次复用 Turnstile...")
|
|
try:
|
|
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
|
|
if token:
|
|
synced = page.run_js(
|
|
"""
|
|
const token = String(arguments[0] || '').trim();
|
|
const cfInput = document.querySelector('input[name="cf-turnstile-response"]');
|
|
if (!cfInput || !token) return false;
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
|
if (nativeSetter) nativeSetter.call(cfInput, token);
|
|
else cfInput.value = token;
|
|
cfInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
cfInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return String(cfInput.value || '').trim().length;
|
|
""",
|
|
token,
|
|
)
|
|
if log_callback:
|
|
log_callback(f"[*] 最终页 Turnstile 二次复用完成,回填长度={synced}")
|
|
except Exception as cf_exc:
|
|
if log_callback:
|
|
log_callback(f"[Debug] 最终页 Turnstile 二次复用失败: {cf_exc}")
|
|
last_cf_retry_at = now
|
|
|
|
cookies = page.cookies(all_domains=True, all_info=True) or []
|
|
for item in cookies:
|
|
if isinstance(item, dict):
|
|
name = str(item.get("name", "")).strip()
|
|
value = str(item.get("value", "")).strip()
|
|
else:
|
|
name = str(getattr(item, "name", "")).strip()
|
|
value = str(getattr(item, "value", "")).strip()
|
|
|
|
if name:
|
|
last_seen_names.add(name)
|
|
|
|
if name == "sso" and value:
|
|
if log_callback:
|
|
log_callback("[*] 已获取到 sso cookie")
|
|
return value
|
|
except PageDisconnectedError:
|
|
refresh_active_page()
|
|
except AccountRetryNeeded:
|
|
raise
|
|
except Exception as exc:
|
|
if log_callback:
|
|
now = time.time()
|
|
message = f"{exc.__class__.__name__}: {exc}"
|
|
if message != last_wait_exception_message or now - last_wait_exception_at >= 10:
|
|
log_callback(f"[Debug] 等待 sso cookie 时出现异常,将继续等待: {message}")
|
|
last_wait_exception_message = message
|
|
last_wait_exception_at = now
|
|
|
|
sleep_with_cancel(1, cancel_callback)
|
|
|
|
raise Exception(
|
|
f"等待超时:未获取到 sso cookie。已看到 cookies: {sorted(last_seen_names)}"
|
|
)
|
|
|
|
|