Harden Turnstile solve path for --fast/Xvfb runs

Add cf_sleep, multi-round shadow clicks, richer diagnostics, and longer profile/CF budgets so tokenLen=0 fails less on headless servers.
This commit is contained in:
chaos committed 2026-07-12 00:58:28 +08:00
1 parent 767516745e
commit 62b6ea8ea2
7 files changed
+391 -129

No files matched your search

+293 -102
View File
@@ -1144,6 +1144,29 @@ def human_sleep(mean_seconds, cancel_callback=None):
sleep_with_cancel(delay, cancel_callback)
def cf_sleep(seconds, cancel_callback=None, *, floor: float = 0.6):
"""Turnstile/Cloudflare 专用等待:fast 模式仍保留足够墙钟时间。
human_sleep 在 --fast 下会被压到 ~0.15x,导致 CF iframe 根本来不及加载/解题。
这里仅轻度缩放,并强制 floor,避免 token 恒为 0。
"""
try:
sec = float(seconds)
except Exception:
sec = 1.0
scale = float(PERF_FLAGS.get("sleep_scale", 1.0) or 1.0)
if PERF_FLAGS.get("fast"):
# 最多压到 0.55x,且不低于 floor
scale = max(0.55, min(scale, 1.0))
delay = max(float(floor), sec * scale)
# 轻微抖动,避免机械节奏
try:
delay = max(float(floor), min(delay * 1.35, random.gauss(delay, delay * 0.12)))
except Exception:
pass
sleep_with_cancel(delay, cancel_callback)
def get_domains(api_key=None):
headers = {}
@@ -3713,32 +3736,11 @@ return 'clicked';
raise Exception("验证码已获取,但自动填写/提交失败")
def getTurnstileToken(log_callback=None, cancel_callback=None, max_rounds: int = 4):
"""Try to obtain a Turnstile token.
max_rounds controls click/reset attempts; each round waits longer for CF.
CDP stealth should already be applied (webdriver hidden).
"""
page = _get_page()
if page is None:
raise Exception("页面未就绪,无法执行 Turnstile")
apply_stealth_patches(page, log_callback=None)
def _read_turnstile_token(page):
"""Read cf-turnstile-response / turnstile.getResponse if present."""
try:
page.run_js(
"try { if (window.turnstile && typeof turnstile.reset === 'function') turnstile.reset(); } catch(e) {}"
)
except Exception:
pass
rounds = max(2, int(max_rounds or 4))
# Give CF more time: ~1.2s * rounds, plus initial attach wait
human_sleep(0.8 if not PERF_FLAGS.get("fast") else 0.35, cancel_callback)
for round_i in range(0, rounds):
raise_if_cancelled(cancel_callback)
try:
token = page.run_js(
"""
token = page.run_js(
"""
try {
const byInput = String((document.querySelector('input[name="cf-turnstile-response"]') || {}).value || '').trim();
if (byInput) return byInput;
@@ -3749,77 +3751,246 @@ try {
}
return '';
} catch(e) { return ''; }
"""
)
token = str(token or "").strip()
"""
)
return str(token or "").strip()
except Exception:
return ""
def _turnstile_diag(page) -> dict:
"""Lightweight CF widget diagnostics for logs (no secrets)."""
try:
info = page.run_js(
"""
try {
const input = document.querySelector('input[name="cf-turnstile-response"], textarea[name="cf-turnstile-response"]');
const iframes = Array.from(document.querySelectorAll(
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"]'
));
const widgets = Array.from(document.querySelectorAll('div.cf-turnstile, [data-sitekey]'));
const srcs = iframes.slice(0, 3).map((f) => {
try { return String(f.src || '').slice(0, 120); } catch(e) { return ''; }
});
return {
hasInput: !!input,
tokenLen: input ? String(input.value || '').trim().length : 0,
iframeCount: iframes.length,
widgetCount: widgets.length,
sitekey: widgets[0] ? String(widgets[0].getAttribute('data-sitekey') || '').slice(0, 16) : '',
iframeSrc0: srcs[0] || '',
turnstileApi: !!(window.turnstile && typeof turnstile.getResponse === 'function'),
webdriver: !!navigator.webdriver,
};
} catch(e) {
return { err: String(e && e.message || e) };
}
"""
)
return info if isinstance(info, dict) else {"raw": str(info)[:200]}
except Exception as exc:
return {"err": str(exc)[:160]}
def _click_turnstile_widget(page, log_callback=None) -> str:
"""Best-effort click path for interactive Turnstile checkbox.
Returns a short tag describing which path succeeded (or 'none').
"""
# Path A: shadow-root checkbox under cf-turnstile-response parent
try:
challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.6)
except Exception:
challenge_input = None
if challenge_input:
iframe = None
try:
wrapper = challenge_input.parent()
iframe = wrapper.shadow_root.ele("tag:iframe")
except Exception:
iframe = None
if iframe is None:
try:
iframe = page.ele(
'css:iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"]',
timeout=0.6,
)
except Exception:
iframe = None
if iframe:
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; } });
Object.defineProperty(MouseEvent.prototype, 'screenY', { get: function(){ return sy; } });
} catch(e) {}
"""
)
except Exception:
pass
# A1: body.shadow_root input / .mark
try:
body_sr = iframe.ele("tag:body").shadow_root
btn = body_sr.ele("tag:input") or body_sr.ele("css:.mark") or body_sr.ele("css:label")
if btn:
try:
btn.click()
except Exception:
try:
btn.click(by_js=True)
except Exception:
page.actions.click(btn)
return "shadow-checkbox"
except Exception:
pass
# A2: click iframe element itself (center)
try:
iframe.click()
return "iframe-click"
except Exception:
pass
try:
page.actions.click(iframe)
return "iframe-actions"
except Exception:
pass
# Path B: DOM query + synthetic pointer events
try:
clicked = page.run_js(
"""
function firePointer(el) {
if (!el) return false;
try { el.scrollIntoView({block:'center', inline:'center'}); } catch(e) {}
const rect = el.getBoundingClientRect();
const x = rect.left + Math.max(8, Math.min(rect.width - 8, rect.width * 0.35));
const y = rect.top + Math.max(8, Math.min(rect.height - 8, rect.height * 0.5));
const opts = {bubbles:true, cancelable:true, view:window, clientX:x, clientY:y, button:0};
for (const t of ['pointerover','pointerenter','mouseover','mouseenter','mousemove','pointerdown','mousedown','pointerup','mouseup','click']) {
try {
const Ev = t.startsWith('pointer') ? PointerEvent : MouseEvent;
el.dispatchEvent(new Ev(t, opts));
} catch(e) {
try { el.dispatchEvent(new MouseEvent(t, opts)); } catch(e2) {}
}
}
try { if (typeof el.click === 'function') el.click(); } catch(e) {}
return true;
}
const nodes = Array.from(document.querySelectorAll(
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey]'
));
let ok = false;
for (const n of nodes) {
if (firePointer(n)) ok = true;
}
return ok;
"""
)
if clicked:
return "js-pointer"
except Exception:
pass
return "none"
def getTurnstileToken(log_callback=None, cancel_callback=None, max_rounds: int = 4):
"""Try to obtain a Turnstile token.
max_rounds controls click/reset attempts; each round waits longer for CF.
CDP stealth should already be applied (webdriver hidden).
Note: --fast compresses human_sleep heavily; this function uses cf_sleep
so CF still has real wall-clock time to attach iframe + solve.
"""
page = _get_page()
if page is None:
raise Exception("页面未就绪,无法执行 Turnstile")
apply_stealth_patches(page, log_callback=None)
rounds = max(3, int(max_rounds or 4))
# Initial attach wait — managed / auto modes often fill without click
cf_sleep(1.8 if not PERF_FLAGS.get("fast") else 1.2, cancel_callback, floor=1.0)
# Passive poll first (don't reset — reset often kills in-flight solve)
for _ in range(4):
raise_if_cancelled(cancel_callback)
token = _read_turnstile_token(page)
if len(token) >= 80:
if log_callback:
log_callback(f"[*] Turnstile 已通过(被动),token长度={len(token)}")
return token
cf_sleep(0.9, cancel_callback, floor=0.7)
diag0 = _turnstile_diag(page)
if log_callback:
log_callback(
f"[Debug] Turnstile 初始态: tokenLen={diag0.get('tokenLen', 0)} "
f"iframe={diag0.get('iframeCount', 0)} widget={diag0.get('widgetCount', 0)} "
f"api={diag0.get('turnstileApi')} webdriver={diag0.get('webdriver')} "
f"sitekey={diag0.get('sitekey', '')!r}"
)
last_click = "none"
for round_i in range(0, rounds):
raise_if_cancelled(cancel_callback)
try:
token = _read_turnstile_token(page)
if len(token) >= 80:
if log_callback:
log_callback(f"[*] Turnstile 已通过,token长度={len(token)}")
return token
challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.5)
if challenge_input:
wrapper = challenge_input.parent()
iframe = None
# Only reset when previous click rounds clearly failed (not on first)
if round_i >= 2 and round_i % 2 == 0:
try:
iframe = wrapper.shadow_root.ele("tag:iframe")
page.run_js(
"try { if (window.turnstile && typeof turnstile.reset === 'function') turnstile.reset(); } catch(e) {}"
)
if log_callback:
log_callback(f"[Debug] Turnstile reset (round={round_i + 1})")
cf_sleep(1.0, cancel_callback, floor=0.8)
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:
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
clicked = False
try:
body_sr = iframe.ele("tag:body").shadow_root
btn = body_sr.ele("tag:input") or body_sr.ele("css:.mark")
if btn:
btn.click()
clicked = True
except Exception:
pass
if not clicked:
try:
iframe.click()
except Exception:
pass
else:
# 兜底:尝试触发页面上可见的 Turnstile 容器 / iframe
page.run_js(
"""
const nodes = Array.from(document.querySelectorAll(
'iframe[src*="challenges.cloudflare.com"], iframe[src*="turnstile"], div.cf-turnstile, [data-sitekey]'
));
for (const n of nodes) {
try { n.scrollIntoView({block:'center', inline:'center'}); } catch(e) {}
try { if (typeof n.click === 'function') n.click(); } catch(e) {}
}
"""
)
except Exception:
pass
# Progressive wait: later rounds wait a bit longer for CF challenge
human_sleep(0.9 + 0.25 * round_i, cancel_callback)
pass
raise Exception("Turnstile 获取 token 失败")
last_click = _click_turnstile_widget(page, log_callback=log_callback)
if log_callback and (round_i == 0 or last_click != "none"):
log_callback(f"[Debug] Turnstile click path={last_click} round={round_i + 1}/{rounds}")
# After click: poll more densely for token
poll_budget = 3.2 + 0.7 * round_i
poll_deadline = time.time() + poll_budget
while time.time() < poll_deadline:
raise_if_cancelled(cancel_callback)
token = _read_turnstile_token(page)
if len(token) >= 80:
if log_callback:
log_callback(f"[*] Turnstile 已通过,token长度={len(token)}")
return token
cf_sleep(0.55, cancel_callback, floor=0.45)
except Exception as exc:
if log_callback and round_i == 0:
log_callback(f"[Debug] Turnstile round error: {exc}")
# Progressive gap between rounds
cf_sleep(1.1 + 0.35 * round_i, cancel_callback, floor=0.9)
diag1 = _turnstile_diag(page)
if log_callback:
log_callback(
f"[Debug] Turnstile 失败态: tokenLen={diag1.get('tokenLen', 0)} "
f"iframe={diag1.get('iframeCount', 0)} last_click={last_click} "
f"src={str(diag1.get('iframeSrc0', ''))[:80]!r}"
)
raise Exception(
f"Turnstile 获取 token 失败 (tokenLen={diag1.get('tokenLen', 0)}, "
f"iframe={diag1.get('iframeCount', 0)}, click={last_click})"
)
def build_profile():
@@ -3854,11 +4025,18 @@ def fill_profile_and_submit(timeout=120, log_callback=None, cancel_callback=None
dump_state(page, "profile-form")
take_screenshot(page, "profile-form")
given_name, family_name, password = build_profile()
# 预热 Turnstile:等 iframe 初始化;CDP stealth 会尝试自动点 checkbox
# 预热 Turnstile:等 iframe 初始化;CDP stealth / extension 会尝试自动点 checkbox
if log_callback:
log_callback("[*] 预热 Turnstile...")
# fast mode scales human_sleep; keep enough time for turnstile iframe to attach
human_sleep(1.6 if not PERF_FLAGS.get("fast") else 0.8, cancel_callback)
# 必须用 cf_sleep:--fast 下 human_sleep 会把预热压到 <0.3s,iframe 来不及挂载
cf_sleep(2.4 if not PERF_FLAGS.get("fast") else 1.6, cancel_callback, floor=1.2)
# 被动预读一次,managed 模式常自动出 token
try:
pre = _read_turnstile_token(page)
if pre and len(pre) >= 80 and log_callback:
log_callback(f"[*] 预热阶段已有 token,长度={len(pre)}")
except Exception:
pass
deadline = time.time() + timeout
form_filled_once = False
wait_cf_since = None
@@ -3866,22 +4044,22 @@ def fill_profile_and_submit(timeout=120, log_callback=None, cancel_callback=None
last_cf_log_at = 0.0
cf_token_fail_streak = 0
try:
# Slightly more patient by default — Chrome CDP stealth still needs CF time
max_cf_token_fails = max(1, int(config.get("turnstile_fast_fail_count", 3) or 3))
# 服务器 Xvfb 上 CF 常需多轮;默认比桌面更耐心
max_cf_token_fails = max(2, int(config.get("turnstile_fast_fail_count", 4) or 4))
except Exception:
max_cf_token_fails = 3
max_cf_token_fails = 4
try:
cf_retry_after = float(config.get("turnstile_retry_after_sec", 10) or 10)
cf_retry_after = float(config.get("turnstile_retry_after_sec", 12) or 12)
except Exception:
cf_retry_after = 10.0
cf_retry_after = 12.0
try:
cf_retry_gap = float(config.get("turnstile_retry_gap_sec", 6) or 6)
cf_retry_gap = float(config.get("turnstile_retry_gap_sec", 8) or 8)
except Exception:
cf_retry_gap = 6.0
cf_retry_gap = 8.0
try:
turnstile_rounds = max(4, int(config.get("turnstile_max_rounds", 6) or 6))
turnstile_rounds = max(5, int(config.get("turnstile_max_rounds", 8) or 8))
except Exception:
turnstile_rounds = 6
turnstile_rounds = 8
while time.time() < deadline:
raise_if_cancelled(cancel_callback)
@@ -3972,6 +4150,14 @@ return 'filled-no-submit';
waited = now - wait_cf_since if wait_cf_since else 0
log_callback(f"[*] 资料已填写,等待 Cloudflare... token长度={token_len} waited={waited:.0f}s")
last_cf_log_at = now
# 卡住前先轻点一次 widget(不 reset),给 managed/interactive 更多时间
if wait_cf_since and now - wait_cf_since >= 3.0 and now - last_cf_retry_at >= 4.0:
try:
path = _click_turnstile_widget(page)
if path != "none" and log_callback and (now - last_cf_log_at >= 4):
log_callback(f"[Debug] 等待期轻点 Turnstile path={path}")
except Exception:
pass
# 卡住后自动二次复用 Turnstile 组件
if now - wait_cf_since >= cf_retry_after and now - last_cf_retry_at >= cf_retry_gap:
if log_callback:
@@ -4003,7 +4189,7 @@ return String(cfInput.value || '').trim().length;
if cf_token_fail_streak >= max_cf_token_fails:
raise Exception(f"Turnstile 连续失败,快速放弃资料页: {cf_exc}")
last_cf_retry_at = now
human_sleep(0.8, cancel_callback)
cf_sleep(1.0, cancel_callback, floor=0.7)
continue
if filled in ("ready-to-submit", "filled-no-submit"):
@@ -4058,6 +4244,11 @@ return 'submitted';
waited = now - wait_cf_since if wait_cf_since else 0
log_callback(f"[*] 等待 Cloudflare 后再提交... token长度={token_len} waited={waited:.0f}s")
last_cf_log_at = now
if wait_cf_since and now - wait_cf_since >= 3.0 and now - last_cf_retry_at >= 4.0:
try:
_click_turnstile_widget(page)
except Exception:
pass
if now - wait_cf_since >= cf_retry_after and now - last_cf_retry_at >= cf_retry_gap:
if log_callback:
log_callback("[*] 提交前仍卡住,自动再次复用 Turnstile...")
@@ -4088,7 +4279,7 @@ return String(cfInput.value || '').trim().length;
if cf_token_fail_streak >= max_cf_token_fails:
raise Exception(f"Turnstile 连续失败,快速放弃资料页: {cf_exc}")
last_cf_retry_at = now
human_sleep(0.8, cancel_callback)
cf_sleep(1.0, cancel_callback, floor=0.7)
continue
if submit_state == "submitted":