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:
1 parent
767516745e
commit
62b6ea8ea2
7 files changed
+391
-129
No files matched your search
+293
-102
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user