/** * Cloudflare 挑战统一处理:检测 → 会话快速通道 → 内置等待 → Captcha API 兜底 * * 当 antiBot.postCfReadyUrlContains 已配置时,session_reuse 仅在业务 URL 已到达时才走快速通道; * 否则清除可能过期的 cf_clearance 并重新触发 Turnstile(Whatshub 短链 → work-order-sharing 场景)。 */ const { detectCloudflare } = require('./cf-detector'); const { waitForTurnstile, extractTurnstileSitekey, injectTurnstileToken, waitForTurnstileSurface } = require('./turnstile-handler'); const { solveTurnstile, isCaptchaConfigured } = require('./captcha-solver'); const { isSessionLikelyValid } = require('./session-store'); /** * 是否配置了 CF 后必须到达的业务 URL * @param {object} antiBot */ function needsPostCfBusinessUrl(antiBot) { return !!(antiBot?.postCfReadyUrlContains || '').trim(); } /** * 当前页面 URL 是否已包含业务路径片段 * @param {import('puppeteer').Page} page * @param {object} antiBot */ function isPostCfBusinessUrlReady(page, antiBot) { const needle = (antiBot?.postCfReadyUrlContains || '').trim(); if (!needle) { return true; } return page.url().includes(needle); } /** * 清除 cf_clearance,避免「有 cookie 但 SPA 未触发跳转」的假过关 * @param {import('puppeteer').Page} page */ async function clearCfClearanceCookies(page) { const cookies = await page.cookies(); for (const c of cookies) { if (c.name !== 'cf_clearance') { continue; } const host = (c.domain || '').replace(/^\./, ''); if (host) { await page.deleteCookie({ name: c.name, url: `https://${host}/` }).catch(() => {}); } await page.deleteCookie({ name: c.name, domain: c.domain, path: c.path || '/', }).catch(() => {}); } } /** * reload 后检查业务 URL 是否就绪 * @param {import('puppeteer').Page} page * @param {(page: import('puppeteer').Page) => Promise} [waitForUrlSettled] * @param {number} timeoutMs * @param {object} antiBot */ async function tryPostCfRecoverViaReload(page, waitForUrlSettled, timeoutMs, antiBot) { if (!needsPostCfBusinessUrl(antiBot) || isPostCfBusinessUrlReady(page, antiBot)) { return isPostCfBusinessUrlReady(page, antiBot); } console.log(`[CF] 业务 URL 未就绪 current=${page.url()},尝试 reload`); try { await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 45000) }); if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } return isPostCfBusinessUrlReady(page, antiBot); } catch (reloadErr) { console.warn('[CF] reload 失败:', reloadErr.message); return false; } } /** * 清除 cf_clearance 并 reload,使 Turnstile 重新出现 * @param {import('puppeteer').Page} page * @param {(page: import('puppeteer').Page) => Promise} [waitForUrlSettled] * @param {number} timeoutMs */ async function invalidateStaleCfAndReload(page, waitForUrlSettled, timeoutMs, antiBot = null) { console.log('[CF] 清除 cf_clearance 并 reload,重新触发 Turnstile'); await clearCfClearanceCookies(page); await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 45000) }); if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } return detectCloudflare(page, antiBot); } /** * session_reuse 快速返回前校验:有 postCf 要求时必须已到业务页 * @param {import('puppeteer').Page} page * @param {object} antiBot * @param {(page: import('puppeteer').Page) => Promise} [waitForUrlSettled] * @param {number} timeoutMs * @param {number} started * @param {boolean} cfDetected */ async function trySessionReuseFastPath(page, antiBot, waitForUrlSettled, timeoutMs, started, cfDetected) { if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } if (!needsPostCfBusinessUrl(antiBot) || isPostCfBusinessUrlReady(page, antiBot)) { return { success: true, code: null, challengeType: null, stage: 'session_reuse', solverUsed: false, elapsedMs: Date.now() - started, cfDetected, }; } console.log(`[CF] cf_clearance/无挑战但业务页未就绪 url=${page.url()},降级重试`); if (await tryPostCfRecoverViaReload(page, waitForUrlSettled, timeoutMs, antiBot)) { return { success: true, code: null, challengeType: null, stage: 'session_reuse_reload', solverUsed: false, elapsedMs: Date.now() - started, cfDetected, }; } return null; } /** * @param {import('puppeteer').Page} page * @param {{ turnstile?: boolean, solverFallback?: boolean, challengeTimeoutMs?: number, postCfReadyUrlContains?: string }} antiBot * @param {(page: import('puppeteer').Page) => Promise} [waitForUrlSettled] * @param {import('./session-store').StoredSession|null} [storedSession] */ async function handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession = null) { const started = Date.now(); const timeoutMs = antiBot.challengeTimeoutMs || 60000; let cfState = await detectCloudflare(page, antiBot); if (!cfState.blocked && cfState.hasCfClearance) { const fast = await trySessionReuseFastPath( page, antiBot, waitForUrlSettled, timeoutMs, started, false ); if (fast) { return fast; } cfState = await invalidateStaleCfAndReload(page, waitForUrlSettled, timeoutMs, antiBot); } if (!cfState.blocked) { if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } if (!needsPostCfBusinessUrl(antiBot) || isPostCfBusinessUrlReady(page, antiBot)) { return { success: true, code: null, challengeType: null, stage: storedSession && isSessionLikelyValid(storedSession) ? 'session_reuse' : null, solverUsed: false, elapsedMs: Date.now() - started, cfDetected: false, }; } if (await tryPostCfRecoverViaReload(page, waitForUrlSettled, timeoutMs, antiBot)) { return { success: true, code: null, challengeType: null, stage: 'post_nav_reload', solverUsed: false, elapsedMs: Date.now() - started, cfDetected: false, }; } if (cfState.hasCfClearance) { cfState = await invalidateStaleCfAndReload(page, waitForUrlSettled, timeoutMs, antiBot); } else { return { success: true, code: null, challengeType: null, stage: 'pending_business_nav', solverUsed: false, elapsedMs: Date.now() - started, cfDetected: false, }; } } if (storedSession && isSessionLikelyValid(storedSession)) { console.log('[CF] 会话有效但仍被拦截,尝试 reload 一次'); try { await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 45000) }); if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } cfState = await detectCloudflare(page, antiBot); if (!cfState.blocked) { if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } return { success: true, code: null, challengeType: cfState.challengeType, stage: 'session_reload', solverUsed: false, elapsedMs: Date.now() - started, cfDetected: true, }; } } catch (reloadErr) { console.warn('[CF] reload 失败:', reloadErr.message); } } console.log(`[CF] 检测到挑战 type=${cfState.challengeType} url=${cfState.url}`); if (antiBot.turnstile !== false) { const builtIn = await waitForTurnstile(page, { timeoutMs, useBuiltInClick: true }); if (builtIn.success) { if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } cfState = await detectCloudflare(page, antiBot); if (!cfState.blocked) { return { success: true, code: null, challengeType: cfState.challengeType, stage: 'built_in', solverUsed: false, elapsedMs: Date.now() - started, cfDetected: true, }; } } } if (antiBot.solverFallback !== false && isCaptchaConfigured()) { try { if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } let sitekey = await waitForTurnstileSurface(page, Math.min(timeoutMs, 25000)); if (!sitekey) { sitekey = await extractTurnstileSitekey(page); } const pageUrl = page.url(); console.log(`[CF] Captcha API 求解 pageUrl=${pageUrl} sitekey=${sitekey ? 'ok' : 'missing'}`); const { token, provider } = await solveTurnstile({ pageUrl, sitekey }); console.log(`[CF] Captcha API (${provider}) 返回 token,注入页面`); await injectTurnstileToken(page, token); const apiWaitMs = Math.min(timeoutMs, 30000); await waitForTurnstile(page, { timeoutMs: apiWaitMs }); if (waitForUrlSettled) { await waitForUrlSettled(page).catch(() => {}); } cfState = await detectCloudflare(page, antiBot); if (!cfState.blocked) { return { success: true, code: null, challengeType: 'turnstile', stage: 'api', solverUsed: true, elapsedMs: Date.now() - started, cfDetected: true, }; } } catch (apiErr) { console.error('[CF] Captcha API 兜底失败:', apiErr.message); return { success: false, code: 'CF_TURNSTILE_FAILED', challengeType: cfState.challengeType || 'turnstile', stage: 'api', solverUsed: true, elapsedMs: Date.now() - started, cfDetected: true, }; } } return { success: false, code: 'CF_TURNSTILE_FAILED', challengeType: cfState.challengeType || 'unknown', stage: antiBot.solverFallback !== false && !isCaptchaConfigured() ? 'built_in' : 'timeout', solverUsed: false, elapsedMs: Date.now() - started, cfDetected: true, }; } module.exports = { handleCloudflareChallenge, isCaptchaConfigured, needsPostCfBusinessUrl, isPostCfBusinessUrlReady, clearCfClearanceCookies, };